Merge branch 'stable-3.1' into stable-3.2

* stable-3.1:
  Upgrade bazlets to latest stable-3.1 to build with 3.1.12 API
  Use username from notedb in serviceuser.db
  Upgrade bazlets to latest stable-3.1 to build with 3.1.11 API

Change-Id: Id179a6b1f1778c8f6a99fd78d6a08b5456763b34
diff --git a/.bazelrc b/.bazelrc
deleted file mode 100644
index 3ae03ff..0000000
--- a/.bazelrc
+++ /dev/null
@@ -1,2 +0,0 @@
-build --workspace_status_command="python ./tools/workspace_status.py"
-test --build_tests_only
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/.eslintignore
diff --git a/.eslintrc.json b/.eslintrc.json
index 3a9c4b9..b586e29 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,79 +1,166 @@
 {
-    "extends": ["eslint:recommended", "google"],
-    "parserOptions": {
-      "ecmaVersion": 8
-    },
-    "env": {
-      "browser": true,
-      "es6": true
-    },
-    "globals": {
-      "__dirname": false,
-      "app": false,
-      "page": false,
-      "Polymer": false,
-      "process": false,
-      "require": false,
-      "Gerrit": false,
-      "Promise": false,
-      "assert": false,
-      "test": false,
-      "flushAsynchronousOperations": false
-    },
-    "rules": {
-      "arrow-parens": ["error", "as-needed"],
-      "block-spacing": ["error", "always"],
-      "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
-      "camelcase": "off",
-      "comma-dangle": ["error", "always-multiline"],
-      "eol-last": "off",
-      "indent": "off",
-      "indent-legacy": ["error", 2, {
-        "MemberExpression": 2,
-        "FunctionDeclaration": {"body": 1, "parameters": 2},
-        "FunctionExpression": {"body": 1, "parameters": 2},
-        "CallExpression": {"arguments": 2},
-        "ArrayExpression": 1,
-        "ObjectExpression": 1,
-        "SwitchCase": 1
-      }],
-      "keyword-spacing": ["error", { "after": true, "before": true }],
-      "max-len": [
-        "error",
-        80,
-        2,
-        {"ignoreComments": true}
-      ],
-      "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
-      "no-console": "off",
-      "no-restricted-syntax": [
-        "error",
-        {
-          "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-          "message": "Remove test.only."
-        },
-        {
-          "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-          "message": "Remove suite.only."
-        }
-      ],
-      "no-undef": "off",
-      "no-useless-escape": "off",
-      "no-var": "error",
-      "object-shorthand": ["error", "always"],
-      "prefer-arrow-callback": "error",
-      "prefer-const": "error",
-      "prefer-spread": "error",
-      "quote-props": ["error", "consistent-as-needed"],
-      "require-jsdoc": "off",
-      "semi": [2, "always"],
-      "template-curly-spacing": "error",
-      "valid-jsdoc": "off"
-    },
-    "plugins": [
-      "html"
+  "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 8,
+    "sourceType": "module"
+  },
+  "env": {
+    "browser": true,
+    "es6": true
+  },
+  "globals": {
+    "__dirname": false,
+    "app": false,
+    "page": false,
+    "Polymer": false,
+    "process": false,
+    "require": false,
+    "Gerrit": false,
+    "Promise": false,
+    "assert": false,
+    "test": false,
+    "flushAsynchronousOperations": false
+  },
+  "rules": {
+    "arrow-parens": ["error", "as-needed"],
+    "block-spacing": ["error", "always"],
+    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
+    "camelcase": "off",
+    "comma-dangle": ["error", {
+      "arrays": "always-multiline",
+      "objects": "always-multiline",
+      "imports": "always-multiline",
+      "exports": "always-multiline",
+      "functions": "never"
+    }],
+    "eol-last": "off",
+    "indent": ["error", 2, {
+      "MemberExpression": 2,
+      "FunctionDeclaration": {"body": 1, "parameters": 2},
+      "FunctionExpression": {"body": 1, "parameters": 2},
+      "CallExpression": {"arguments": 2 },
+      "ArrayExpression": 1,
+      "ObjectExpression": 1,
+      "SwitchCase": 1
+    }],
+    "keyword-spacing": ["error", { "after": true, "before": true }],
+    "lines-between-class-members": ["error", "always"],
+    "max-len": [
+      "error",
+      80,
+      2,
+      {
+        "ignoreComments": true,
+        "ignorePattern": "^import .*;$"
+      }
     ],
-    "settings": {
-      "html/report-bad-indent": "error"
+    "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
+    "no-console": "off",
+    "no-multiple-empty-lines": [ "error", { "max": 1 } ],
+    "no-prototype-builtins": "off",
+    "no-redeclare": "off",
+    "no-restricted-syntax": [
+      "error",
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
+        "message": "Remove test.only."
+      },
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
+        "message": "Remove suite.only."
+      }
+    ],
+    "no-undef": "off",
+    "no-useless-escape": "off",
+    "no-var": "error",
+    "object-shorthand": ["error", "always"],
+    "padding-line-between-statements": [
+      "error",
+      {
+        "blankLine": "always",
+        "prev": "class",
+        "next": "*"
+      },
+      {
+        "blankLine": "always",
+        "prev": "*",
+        "next": "class"
+      }
+    ],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "error",
+    "prefer-spread": "error",
+    "quote-props": ["error", "consistent-as-needed"],
+    "require-jsdoc": "off",
+    "semi": [2, "always"],
+    "template-curly-spacing": "error",
+    "valid-jsdoc": "off",
+    "require-jsdoc": 0,
+    "valid-jsdoc": 0,
+    "jsdoc/check-alignment": 2,
+    "jsdoc/check-examples": 0,
+    "jsdoc/check-indentation": 0,
+    "jsdoc/check-param-names": 0,
+    "jsdoc/check-syntax": 0,
+    "jsdoc/check-tag-names": 0,
+    "jsdoc/check-types": 0,
+    "jsdoc/implements-on-classes": 2,
+    "jsdoc/match-description": 0,
+    "jsdoc/newline-after-description": 2,
+    "jsdoc/no-types": 0,
+    "jsdoc/no-undefined-types": 0,
+    "jsdoc/require-description": 0,
+    "jsdoc/require-description-complete-sentence": 0,
+    "jsdoc/require-example": 0,
+    "jsdoc/require-hyphen-before-param-description": 0,
+    "jsdoc/require-jsdoc": 0,
+    "jsdoc/require-param": 0,
+    "jsdoc/require-param-description": 0,
+    "jsdoc/require-param-name": 2,
+    "jsdoc/require-param-type": 2,
+    "jsdoc/require-returns": 0,
+    "jsdoc/require-returns-check": 0,
+    "jsdoc/require-returns-description": 0,
+    "jsdoc/require-returns-type": 2,
+    "jsdoc/valid-types": 2,
+    "jsdoc/require-file-overview": ["error", {
+      "tags": {
+        "license": {
+          "mustExist": true,
+          "preventDuplicates": true
+        }
+      }
+    }],
+    "import/named": 2,
+    "import/no-unresolved": 2,
+    "import/no-self-import": 2,
+    // The no-cycle rule is slow, because it doesn't cache dependencies.
+    // Disable it.
+    "import/no-cycle": 0,
+    "import/no-useless-path-segments": 2,
+    "import/no-unused-modules": 2,
+    "import/no-default-export": 2
+  },
+  "plugins": [
+    "html",
+    "jsdoc",
+    "import"
+  ],
+  "settings": {
+    "html/report-bad-indent": "error"
+  },
+  "overrides": [
+    {
+      "files": ["*_html.js", "*-styles.js"],
+      "rules": {
+        "max-len": "off"
+      }
+    },
+    {
+      "files": ["*.html"],
+      "rules": {
+        "jsdoc/require-file-overview": "off"
+      }
     }
-  }
+  ]
+}
diff --git a/.gitignore b/.gitignore
index acae119..a94d0d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-/bazel-*
 /eclipse-out
 /target
 /.classpath
diff --git a/BUILD b/BUILD
index fcad0e5..303a8ac 100644
--- a/BUILD
+++ b/BUILD
@@ -1,3 +1,7 @@
+load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("//tools/bzl:js.bzl", "polygerrit_plugin")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/js:eslint.bzl", "eslint")
 load("//tools/bzl:junit.bzl", "junit_tests")
 load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS", "PLUGIN_TEST_DEPS", "gerrit_plugin")
 
@@ -10,6 +14,7 @@
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.serviceuser.HttpModule",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.serviceuser.SshModule",
     ],
+    resource_jars = [":gr-serviceuser-static"],
     resources = glob(["src/main/resources/**/*"]),
 )
 
@@ -24,3 +29,54 @@
         ":serviceuser__plugin",
     ],
 )
+
+genrule2(
+    name = "gr-serviceuser-static",
+    srcs = [":gr-serviceuser"],
+    outs = ["gr-serviceuser-static.jar"],
+    cmd = " && ".join([
+        "mkdir $$TMP/static",
+        "cp -r $(locations :gr-serviceuser) $$TMP/static",
+        "cd $$TMP",
+        "zip -Drq $$ROOT/$@ -g .",
+    ]),
+)
+
+rollup_bundle(
+    name = "serviceuser-bundle",
+    srcs = glob(["gr-serviceuser/*.js"]),
+    entry_point = "gr-serviceuser/gr-serviceuser.js",
+    format = "iife",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    sourcemap = "hidden",
+    deps = [
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
+
+polygerrit_plugin(
+    name = "gr-serviceuser",
+    app = "serviceuser-bundle.js",
+    plugin_name = "serviceuser",
+)
+
+# Define the eslinter for the plugin
+# The eslint macro creates 2 rules: lint_test and lint_bin
+eslint(
+    name = "lint",
+    srcs = glob([
+        "gr-serviceuser/**/*.js",
+    ]),
+    config = ".eslintrc.json",
+    data = [],
+    extensions = [
+        ".js",
+    ],
+    ignore = ".eslintignore",
+    plugins = [
+        "@npm//eslint-config-google",
+        "@npm//eslint-plugin-html",
+        "@npm//eslint-plugin-import",
+        "@npm//eslint-plugin-jsdoc",
+    ],
+)
diff --git a/WORKSPACE b/WORKSPACE
deleted file mode 100644
index c771577..0000000
--- a/WORKSPACE
+++ /dev/null
@@ -1,15 +0,0 @@
-workspace(name = "serviceuser")
-
-load("//:bazlets.bzl", "load_bazlets")
-
-load_bazlets(
-    commit = "87fd5f0d0a89d01df13deaf2d21a4bdb3bc03cfd",
-    #local_path = "/home/<user>/projects/bazlets",
-)
-
-load(
-    "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
-    "gerrit_api",
-)
-
-gerrit_api()
diff --git a/bazlets.bzl b/bazlets.bzl
deleted file mode 100644
index f089af4..0000000
--- a/bazlets.bzl
+++ /dev/null
@@ -1,18 +0,0 @@
-load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
-
-NAME = "com_googlesource_gerrit_bazlets"
-
-def load_bazlets(
-        commit,
-        local_path = None):
-    if not local_path:
-        git_repository(
-            name = NAME,
-            remote = "https://gerrit.googlesource.com/bazlets",
-            commit = commit,
-        )
-    else:
-        native.local_repository(
-            name = NAME,
-            path = local_path,
-        )
diff --git a/gr-serviceuser/gr-serviceuser-create.js b/gr-serviceuser/gr-serviceuser-create.js
new file mode 100644
index 0000000..4ca98d9
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser-create.js
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import {htmlTemplate} from './gr-serviceuser-create_html.js';
+
+export class GrServiceUserCreate extends Polymer.GestureEventListeners(
+    Polymer.Element) {
+  /** @returns {?} template for this component */
+  static get template() { return htmlTemplate; }
+
+  /** @returns {string} name of the component */
+  static get is() { return 'gr-serviceuser-create'; }
+
+  /**
+   * Defines properties of the component
+   *
+   * @returns {?}
+   */
+  static get properties() {
+    return {
+      _infoMessageEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _infoMessage: String,
+      _successMessageEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _successMessage: String,
+      _newUsername: String,
+      _emailEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _newEmail: String,
+      _newKey: String,
+      _dataValid: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdding: {
+        type: Boolean,
+        value: false,
+      },
+      _enableButton: {
+        type: Boolean,
+        value: false,
+      },
+      _accountId: String,
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._getConfig();
+  }
+
+  _forwardToDetails() {
+    page.show(
+        this.plugin.screenUrl()
+        + '/user/'
+        + this._accountId);
+  }
+
+  _getConfig() {
+    return this.plugin.restApi('/config/server/serviceuser~config/').get('')
+        .then(config => {
+          if (!config) {
+            return;
+          }
+
+          if (config.info && config.info != '') {
+            this._infoMessageEnabled = true;
+            this._infoMessage = config.info;
+            this.$.infoMessage.innerHTML = this._infoMessage;
+          }
+
+          if (config.on_success && config.on_success != '') {
+            this._successMessageEnabled = true;
+            this._successMessage = config.on_success;
+            this.$.successMessage.innerHTML = this._successMessage;
+          }
+
+          this._emailEnabled = config.allow_email;
+        });
+  }
+
+  _validateData() {
+    this._dataValid = this._validateName(this._newUsername)
+      && this._validateEmail(this._newEmail)
+      && this._validateKey(this._newKey);
+    this._computeButtonEnabled();
+  }
+
+  _validateName(username) {
+    if (username && username.trim().length > 0) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _validateEmail(email) {
+    if (!email || email.trim().length == 0 || email.includes('@')) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _validateKey(key) {
+    if (!key || !key.trim()) {
+      return false;
+    }
+
+    return true;
+  }
+
+  _computeButtonEnabled() {
+    this._enableButton = this._dataValid && !this._isAdding;
+  }
+
+  _handleCreateServiceUser() {
+    this._isAdding = true;
+    this._computeButtonEnabled();
+    const body = {
+      ssh_key: this._newKey.trim(),
+      email: this._newEmail ? this._newEmail.trim() : null,
+    };
+    return this.plugin.restApi('/a/config/server/serviceuser~serviceusers/')
+        .post(this._newUsername, body)
+        .then(response => {
+          this._accountId = response._account_id;
+          if (this._successMessage) {
+            this.$.successDialogOverlay.open();
+          } else {
+            this._forwardToDetails();
+          }
+        }).catch(response => {
+          this.dispatchEvent(
+              new CustomEvent(
+                  'show-error',
+                  {
+                    detail: {message: response},
+                    bubbles: true,
+                    composed: true,
+                  }
+              )
+          );
+          this._isAdding = false;
+          this._computeButtonEnabled();
+        });
+  }
+}
+
+customElements.define(GrServiceUserCreate.is, GrServiceUserCreate);
diff --git a/src/main/resources/static/gr-serviceuser-create.html b/gr-serviceuser/gr-serviceuser-create_html.js
similarity index 75%
rename from src/main/resources/static/gr-serviceuser-create.html
rename to gr-serviceuser/gr-serviceuser-create_html.js
index 2e70471..6cb9626 100644
--- a/src/main/resources/static/gr-serviceuser-create.html
+++ b/gr-serviceuser/gr-serviceuser-create_html.js
@@ -1,22 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
 
-Licensed 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.
--->
-
-<dom-module id="gr-serviceuser-create">
-  <template>
+export const htmlTemplate = Polymer.html`
     <style include="shared-styles"></style>
     <style include="gr-subpage-styles"></style>
     <style include="gr-form-styles"></style>
@@ -88,6 +87,4 @@
         </gr-dialog>
       </gr-overlay>
     </main>
-  </template>
-  <script src="gr-serviceuser-create.js"></script>
-</dom-module>
+`;
diff --git a/gr-serviceuser/gr-serviceuser-detail.js b/gr-serviceuser/gr-serviceuser-detail.js
new file mode 100644
index 0000000..fa8fd32
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser-detail.js
@@ -0,0 +1,373 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import './gr-serviceuser-ssh-panel.js';
+import './gr-serviceuser-http-password.js';
+import {htmlTemplate} from './gr-serviceuser-detail_html.js';
+
+const NOT_FOUND_MESSAGE = 'Not Found';
+
+export class GrServiceUserDetail extends Polymer.GestureEventListeners(
+    Polymer.Element) {
+  /** @returns {?} template for this component */
+  static get template() { return htmlTemplate; }
+
+  /** @returns {string} name of the component */
+  static get is() { return 'gr-serviceuser-detail'; }
+
+  /**
+   * Defines properties of the component
+   *
+   * @returns {?}
+   */
+  static get properties() {
+    return {
+      _restApi: Object,
+      _serviceUserId: String,
+      _serviceUser: Object,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _statusButtonText: {
+        type: String,
+        value: 'Activate',
+      },
+      _prefsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _changingPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _allowEmail: {
+        type: Boolean,
+        value: false,
+      },
+      _allowOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _allowHttpPassword: {
+        type: Boolean,
+        value: false,
+      },
+      _newFullName: String,
+      _newEmail: String,
+      _availableOwners: Array,
+      _newOwner: String,
+      _ownerChangeWarning: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
+    };
+  }
+
+  static get behaviors() {
+    return [
+      Gerrit.ListViewBehavior,
+    ];
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._extractUserId();
+    this._loadServiceUser();
+  }
+
+  _loadServiceUser() {
+    if (!this._serviceUserId) { return; }
+
+    const promises = [];
+
+    promises.push(this._getPluginConfig());
+    promises.push(this._getServiceUser());
+
+    Promise.all(promises).then(() => {
+      this.$.sshEditor.loadData(this._restApi, this._serviceUser);
+      this.$.httpPass.loadData(this._restApi, this._serviceUser);
+
+      this.dispatchEvent(
+          new CustomEvent(
+              'title-change',
+              {
+                detail: {title: this._serviceUser.name},
+                bubbles: true,
+                composed: true,
+              }
+          )
+      );
+      this._computeStatusButtonText();
+      this._loading = false;
+      this._newFullName = this._serviceUser.name;
+      this._newEmail = this._serviceUser.email;
+    });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _extractUserId() {
+    this._serviceUserId = this.baseURI.split('/').pop();
+  }
+
+  _getPermissions() {
+    return this.plugin.restApi().getAccountCapabilities(['administrateServer'])
+        .then(capabilities => {
+          this._isAdmin = capabilities && capabilities.administrateServer;
+        });
+  }
+
+  _getPluginConfig() {
+    return Promise.resolve(this._getPermissions()).then(() => {
+      this.plugin.restApi('/config/server/serviceuser~config/').get('')
+          .then(config => {
+            if (!config) {
+              return;
+            }
+            this._allowEmail = config.allow_email || this._isAdmin;
+            this._allowOwner = config.allow_owner || this._isAdmin;
+            this._allowHttpPassword = config.allow_http_password
+              || this._isAdmin;
+          });
+    });
+  }
+
+  _getServiceUser() {
+    this._restApi = this.plugin.restApi(
+        '/a/config/server/serviceuser~serviceusers/');
+    return this._restApi.get(this._serviceUserId)
+        .then(serviceUser => {
+          if (!serviceUser) {
+            this._serviceUser = {};
+            return;
+          }
+          this._serviceUser = serviceUser;
+        });
+  }
+
+  _active(serviceUser) {
+    if (!serviceUser) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    return serviceUser.inactive === true ? 'Inactive' : 'Active';
+  }
+
+  _computeStatusButtonText() {
+    if (!this._serviceUser) {
+      return;
+    }
+
+    this._statusButtonText = this._serviceUser.inactive === true
+      ? 'Activate'
+      : 'Deactivate';
+  }
+
+  _toggleStatus() {
+    if (this._serviceUser.inactive === true) {
+      this._restApi.put(`${this._serviceUser._account_id}/active`)
+          .then(() => {
+            this._loadServiceUser();
+          });
+    } else {
+      this._restApi.delete(`${this._serviceUser._account_id}/active`)
+          .then(() => {
+            this._loadServiceUser();
+          });
+    }
+  }
+
+  _getCreator(serviceUser) {
+    if (!serviceUser || !serviceUser.created_by) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    if (serviceUser.created_by.username != undefined) {
+      return serviceUser.created_by.username;
+    }
+
+    if (serviceUser.created_by._account_id != -1) {
+      return serviceUser.created_by._account_id;
+    }
+
+    return NOT_FOUND_MESSAGE;
+  }
+
+  _getOwnerGroup(serviceUser) {
+    return serviceUser && serviceUser.owner
+      ? serviceUser.owner.name
+      : NOT_FOUND_MESSAGE;
+  }
+
+  _isEmailValid(email) {
+    if (!email) {
+      return false;
+    }
+    return email.includes('@');
+  }
+
+  _getGroupSuggestions(input) {
+    let query;
+    if (!input || input === this._getOwnerGroup(this._serviceUser)) {
+      query = '';
+    } else {
+      query = `?suggest=${input}`;
+    }
+
+    return this.plugin.restApi('/a/groups/').get(query)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          this._availableOwners = groups;
+          return groups;
+        });
+  }
+
+  _isOwnerValid(owner) {
+    if (!owner) {
+      return false;
+    }
+
+    return this._getOwnerName(owner);
+  }
+
+  _isNewOwner() {
+    return this._getOwnerName(this._newOwner)
+        === this._getOwnerGroup(this._serviceUser);
+  }
+
+  _getOwnerName(id) {
+    return this._availableOwners.find(o => { return o.value === id; }).name;
+  }
+
+  _computeOwnerWarning() {
+    let message = 'If ';
+    message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+      ? 'the owner group is changed' : 'an owner group is set';
+    message += ' only members of the ';
+    message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+      ? 'new ' : '';
+    message += 'owner group can see and administrate the service user.';
+    message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+      ? '' : ' The creator of the service user can no'
+        + ' longer see and administrate the service user if she/he'
+        + ' is not member of the owner group.';
+    this._ownerChangeWarning = message;
+  }
+
+  _computePrefsChanged() {
+    if (this.loading || this._changingPrefs) {
+      return;
+    }
+
+    if (!this._newOwner && !this._newEmail && !this._newFullName) {
+      this._prefsChanged = false;
+      return;
+    }
+
+    if (this._newEmail && !this._isEmailValid(this._newEmail)) {
+      this._prefsChanged = false;
+      return;
+    }
+
+    if (this._newOwner
+        && (this._isNewOwner() || !this._isOwnerValid(this._newOwner))) {
+      this._prefsChanged = false;
+      return;
+    }
+
+    if (this._newOwner) {
+      this._computeOwnerWarning();
+    }
+
+    this._prefsChanged = true;
+  }
+
+  _applyNewFullName() {
+    return this._restApi
+        .put(`${this._serviceUser._account_id}/name`,
+            {name: this._newFullName})
+        .then(() => {
+          this.$.serviceUserFullNameInput.value = '';
+        });
+  }
+
+  _applyNewEmail(email) {
+    if (!this._isEmailValid(email)) {
+      return;
+    }
+    return this._restApi
+        .put(`${this._serviceUser._account_id}/email`, {email})
+        .then(() => {
+          this.$.serviceUserEmailInput.value = '';
+        });
+  }
+
+  _applyNewOwner(owner) {
+    if (this._isNewOwner() || !this._isOwnerValid(this._newOwner)) {
+      return;
+    }
+    return this._restApi
+        .put(`${this._serviceUser._account_id}/owner`, {group: owner})
+        .then(() => {
+          this.$.serviceUserOwnerInput.text = this._getOwnerGroup(
+              this._serviceUser);
+        });
+  }
+
+  _handleSavePreferences() {
+    const promises = [];
+    this._changingPrefs = true;
+
+    if (this._newFullName) {
+      promises.push(this._applyNewFullName());
+    }
+
+    if (this._newEmail) {
+      promises.push(this._applyNewEmail(this._newEmail));
+    }
+
+    if (this._newOwner) {
+      promises.push(this._applyNewOwner(this._newOwner));
+    }
+
+    Promise.all(promises).then(() => {
+      this._changingPrefs = false;
+      this._prefsChanged = false;
+      this._ownerChangeWarning = '';
+      this._loadServiceUser();
+    });
+  }
+}
+
+customElements.define(GrServiceUserDetail.is, GrServiceUserDetail);
diff --git a/src/main/resources/static/gr-serviceuser-detail.html b/gr-serviceuser/gr-serviceuser-detail_html.js
similarity index 86%
rename from src/main/resources/static/gr-serviceuser-detail.html
rename to gr-serviceuser/gr-serviceuser-detail_html.js
index 41e4e8d..a6f2408 100644
--- a/src/main/resources/static/gr-serviceuser-detail.html
+++ b/gr-serviceuser/gr-serviceuser-detail_html.js
@@ -1,25 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
 
-Licensed 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.
--->
-
-<link rel="import" href="./gr-serviceuser-ssh-panel.html">
-<link rel="import" href="./gr-serviceuser-http-password.html">
-
-<dom-module id="gr-serviceuser-detail">
-  <template>
+export const htmlTemplate = Polymer.html`
     <style include="shared-styles"></style>
     <style include="gr-subpage-styles"></style>
     <style include="gr-form-styles"></style>
@@ -164,6 +160,4 @@
         </div>
       </main>
     </div>
-  </template>
-  <script src="gr-serviceuser-detail.js"></script>
-</dom-module>
+`;
diff --git a/gr-serviceuser/gr-serviceuser-http-password.js b/gr-serviceuser/gr-serviceuser-http-password.js
new file mode 100644
index 0000000..396eef7
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser-http-password.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import {htmlTemplate} from './gr-serviceuser-http-password_html.js';
+
+class GrServiceUserHttpPassword extends Polymer.GestureEventListeners(
+    Polymer.Element) {
+  /** @returns {?} template for this component */
+  static get template() { return htmlTemplate; }
+
+  /** @returns {string} name of the component */
+  static get is() { return 'gr-serviceuser-http-password'; }
+
+  /**
+   * Defines properties of the component
+   *
+   * @returns {?}
+   */
+  static get properties() {
+    return {
+      _restApi: Object,
+      _serviceUser: Object,
+      _generatedPassword: String,
+      _passwordUrl: String,
+    };
+  }
+
+  loadData(restApi, serviceUser) {
+    this._restApi = restApi;
+    this._serviceUser = serviceUser;
+  }
+
+  _handleGenerateTap() {
+    this._generatedPassword = 'Generating...';
+    this.$.generatedPasswordOverlay.open();
+    this._restApi
+        .put(`${this._serviceUser._account_id}/password.http`,
+            {generate: true})
+        .then(newPassword => {
+          this._generatedPassword = newPassword;
+        });
+  }
+
+  _closeOverlay() {
+    this.$.generatedPasswordOverlay.close();
+  }
+
+  _generatedPasswordOverlayClosed() {
+    this._generatedPassword = '';
+  }
+
+  _handleDelete() {
+    this._restApi.delete(`${this._serviceUser._account_id}/password.http`);
+  }
+}
+
+customElements.define(GrServiceUserHttpPassword.is, GrServiceUserHttpPassword);
diff --git a/src/main/resources/static/gr-serviceuser-http-password.html b/gr-serviceuser/gr-serviceuser-http-password_html.js
similarity index 69%
rename from src/main/resources/static/gr-serviceuser-http-password.html
rename to gr-serviceuser/gr-serviceuser-http-password_html.js
index 8cebbe5..b48855c 100644
--- a/src/main/resources/static/gr-serviceuser-http-password.html
+++ b/gr-serviceuser/gr-serviceuser-http-password_html.js
@@ -1,22 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
 
-Licensed 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.
--->
-
-<dom-module id="gr-serviceuser-http-password">
-  <template>
+export const htmlTemplate = Polymer.html`
     <style include="shared-styles">
       .password {
         font-family: var(--monospace-font-family);
@@ -76,6 +75,4 @@
                    on-click="_closeOverlay">Close</gr-button>
       </div>
     </gr-overlay>
-  </template>
-  <script src="gr-serviceuser-http-password.js"></script>
-</dom-module>
+`;
diff --git a/gr-serviceuser/gr-serviceuser-list.js b/gr-serviceuser/gr-serviceuser-list.js
new file mode 100644
index 0000000..a3c40c0
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser-list.js
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import {htmlTemplate} from './gr-serviceuser-list_html.js';
+
+const NOT_FOUND_MESSAGE = 'Not Found';
+
+export class GrServiceUserList extends Polymer.GestureEventListeners(
+    Polymer.Element) {
+  /** @returns {?} template for this component */
+  static get template() { return htmlTemplate; }
+
+  /** @returns {string} name of the component */
+  static get is() { return 'gr-serviceuser-list'; }
+
+  /**
+   * Defines properties of the component
+   *
+   * @returns {?}
+   */
+  static get properties() {
+    return {
+      _serviceUsers: Array,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    };
+  }
+
+  static get behaviors() {
+    return [
+      Gerrit.ListViewBehavior,
+    ];
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.dispatchEvent(
+        new CustomEvent(
+            'title-change',
+            {detail: {title: 'Service Users'}, bubbles: true, composed: true}));
+    this._getServiceUsers();
+  }
+
+  _getServiceUsers() {
+    return this.plugin.restApi('/a/config/server/serviceuser~serviceusers/')
+        .get('')
+        .then(serviceUsers => {
+          if (!serviceUsers) {
+            this._serviceUsers = [];
+            return;
+          }
+          this._serviceUsers = Object.keys(serviceUsers)
+              .map(key => {
+                const serviceUser = serviceUsers[key];
+                serviceUser.username = key;
+                return serviceUser;
+              });
+          this._loading = false;
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _active(item) {
+    if (!item) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    return item.inactive === true ? 'Inactive' : 'Active';
+  }
+
+  _getCreator(item) {
+    if (!item || !item.created_by) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    if (item.created_by.username != undefined) {
+      return item.created_by.username;
+    }
+
+    if (item.created_by._account_id != -1) {
+      return item.created_by._account_id;
+    }
+
+    return NOT_FOUND_MESSAGE;
+  }
+
+  _getOwnerGroup(item) {
+    return item && item.owner ? item.owner.name : NOT_FOUND_MESSAGE;
+  }
+
+  _computeServiceUserUrl(id) {
+    return `${this.plugin.screenUrl()}/user/${id}`;
+  }
+
+  _createNewServiceUser() {
+    page.show(this.plugin.screenUrl() + '/create');
+  }
+}
+
+customElements.define(GrServiceUserList.is, GrServiceUserList);
diff --git a/src/main/resources/static/gr-serviceuser-list.html b/gr-serviceuser/gr-serviceuser-list_html.js
similarity index 68%
rename from src/main/resources/static/gr-serviceuser-list.html
rename to gr-serviceuser/gr-serviceuser-list_html.js
index e927d63..6bd9ecc 100644
--- a/src/main/resources/static/gr-serviceuser-list.html
+++ b/gr-serviceuser/gr-serviceuser-list_html.js
@@ -1,22 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
 
-Licensed 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.
--->
-
-<dom-module id="gr-serviceuser-list">
-  <template>
+export const htmlTemplate = Polymer.html`
     <style include="shared-styles"></style>
     <style include="gr-table-styles"></style>
     <style>
@@ -59,10 +58,10 @@
         <th class="accountState topHeader">Account State</th>
       </tr>
       <tr id="loading"
-          class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          class$="loadingMsg [[_computeLoadingClass(_loading)]]">
         <td>Loading...</td>
       </tr>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
+      <tbody class$="[[_computeLoadingClass(_loading)]]">
         <template is="dom-repeat"
                   items="[[_serviceUsers]]">
           <tr class="table">
@@ -79,6 +78,4 @@
         </template>
       </tbody>
     </table>
-  </template>
-  <script src="gr-serviceuser-list.js"></script>
-</dom-module>
+`;
diff --git a/gr-serviceuser/gr-serviceuser-ssh-panel.js b/gr-serviceuser/gr-serviceuser-ssh-panel.js
new file mode 100644
index 0000000..a2db9c0
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser-ssh-panel.js
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import {htmlTemplate} from './gr-serviceuser-ssh-panel_html.js';
+
+class GrServiceUserSshPanel extends Polymer.GestureEventListeners(
+    Polymer.Element) {
+  /** @returns {?} template for this component */
+  static get template() { return htmlTemplate; }
+
+  /** @returns {string} name of the component */
+  static get is() { return 'gr-serviceuser-ssh-panel'; }
+
+  /**
+   * Defines properties of the component
+   *
+   * @returns {?}
+   */
+  static get properties() {
+    return {
+      _restApi: Object,
+      _serviceUser: Object,
+      _keys: Array,
+      /** @type {?} */
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+    };
+  }
+
+  loadData(restApi, serviceUser) {
+    this._restApi = restApi;
+    this._serviceUser = serviceUser;
+    return this._restApi.get(`${this._serviceUser._account_id}/sshkeys`)
+        .then(keys => {
+          if (!keys) {
+            this._keys = [];
+            return;
+          }
+          this._keys = keys;
+        });
+  }
+
+  _getStatusLabel(isValid) {
+    return isValid ? 'Valid' : 'Invalid';
+  }
+
+  _showKey(e) {
+    const el = Polymer.dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
+
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
+
+  _handleDeleteKey(e) {
+    const el = Polymer.dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this.push('_keysToRemove', this._keys[index]);
+
+    const promises = this._keysToRemove.map(key => {
+      this._restApi.delete(
+          `${this._serviceUser._account_id}/sshkeys/${key.seq}`);
+    });
+
+    return Promise.all(promises).then(() => {
+      this.splice('_keys', index, 1);
+      this._keysToRemove = [];
+    });
+  }
+
+  _handleAddKey() {
+    this.$.addButton.disabled = true;
+    this.$.newKey.disabled = true;
+    return this._restApi.post(`${this._serviceUser._account_id}/sshkeys`,
+        this._newKey.trim(), null, 'plain/text')
+        .then(key => {
+          this.push('_keys', key);
+        }).catch(() => {
+          this.$.addButton.disabled = false;
+          this.$.newKey.disabled = false;
+        });
+  }
+
+  _computeAddButtonDisabled(newKey) {
+    return !newKey.length;
+  }
+}
+
+customElements.define(GrServiceUserSshPanel.is, GrServiceUserSshPanel);
diff --git a/src/main/resources/static/gr-serviceuser-ssh-panel.html b/gr-serviceuser/gr-serviceuser-ssh-panel_html.js
similarity index 81%
rename from src/main/resources/static/gr-serviceuser-ssh-panel.html
rename to gr-serviceuser/gr-serviceuser-ssh-panel_html.js
index 2906a10..a9fb7d9 100644
--- a/src/main/resources/static/gr-serviceuser-ssh-panel.html
+++ b/gr-serviceuser/gr-serviceuser-ssh-panel_html.js
@@ -1,22 +1,21 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
 
-Licensed 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.
--->
-
-<dom-module id="gr-serviceuser-ssh-panel">
-  <template>
+export const htmlTemplate = Polymer.html`
     <style include="shared-styles"></style>
     <style include="gr-form-styles">
       .statusHeader {
@@ -127,6 +126,4 @@
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-serviceuser-ssh-panel.js"></script>
-</dom-module>
+`;
diff --git a/gr-serviceuser/gr-serviceuser.js b/gr-serviceuser/gr-serviceuser.js
new file mode 100644
index 0000000..c6624a6
--- /dev/null
+++ b/gr-serviceuser/gr-serviceuser.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+import {GrServiceUserList} from './gr-serviceuser-list.js';
+import {GrServiceUserDetail} from './gr-serviceuser-detail.js';
+import {GrServiceUserCreate} from './gr-serviceuser-create.js';
+
+Gerrit.install(plugin => {
+  plugin.restApi()
+      .getAccountCapabilities([
+        'administrateServer',
+        'serviceuser-createServiceUser'])
+      .then(capabilities => {
+        if (capabilities
+            && (capabilities.administrateServer
+                || capabilities['serviceuser-createServiceUser'])) {
+          plugin.screen('list', GrServiceUserList.is);
+          plugin.screen('user', GrServiceUserDetail.is);
+          plugin.screen('create', GrServiceUserCreate.is);
+        }
+        plugin.admin()
+            .addMenuLink(
+                'Service Users',
+                '/x/serviceuser/list',
+                'serviceuser-createServiceUser');
+      });
+});
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..aa7780e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "serviceuser",
+  "description": "Serviceuser plugin",
+  "browser": true,
+  "scripts": {
+    "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
+    "eslint": "npm run safe_bazelisk test :lint_test",
+    "eslintfix": "npm run safe_bazelisk run :lint_bin -- -- --fix $(pwd)"
+  },
+  "devDependencies": {},
+  "license": "Apache-2.0",
+  "private": true
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java
new file mode 100644
index 0000000..c365fb7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilter.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+@Singleton
+public class BlockedNameFilter {
+  private final PluginConfig cfg;
+  private final Set<String> blockedExactNames = new HashSet<>();
+  private final List<String> blockedNamePrefixes = new ArrayList<>();
+  private final List<Pattern> blockedRegexNames = new ArrayList<>();
+
+  @Inject
+  public BlockedNameFilter(PluginConfigFactory cfgFactory, @PluginName String pluginName) {
+    this.cfg = cfgFactory.getFromGerritConfig(pluginName);
+    parseConfig();
+  }
+
+  public boolean isBlocked(String username) {
+    username = username.toLowerCase();
+    return isBlockedByExactName(username)
+        || isBlockedByWildcard(username)
+        || isBlockedByRegex(username);
+  }
+
+  private void parseConfig() {
+    for (String s : cfg.getStringList("block")) {
+      if (s.startsWith("^")) {
+        blockedRegexNames.add(Pattern.compile(s, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
+      } else if (s.endsWith("*")) {
+        blockedNamePrefixes.add(s.substring(0, s.length() - 1).toLowerCase());
+      } else {
+        blockedExactNames.add(s.toLowerCase());
+      }
+    }
+  }
+
+  private boolean isBlockedByExactName(String username) {
+    return blockedExactNames.contains(username);
+  }
+
+  private boolean isBlockedByWildcard(String username) {
+    for (String prefix : blockedNamePrefixes) {
+      if (username.startsWith(prefix)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean isBlockedByRegex(String username) {
+    for (Pattern p : blockedRegexNames) {
+      if (p.matcher(username).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
index 9a04e0e..3e04e41 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
@@ -16,9 +16,7 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
@@ -36,6 +34,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -55,7 +54,6 @@
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
-import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -79,7 +77,6 @@
 
   private final PluginConfig cfg;
   private final CreateAccount createAccount;
-  private final List<String> blockedNames;
   private final Provider<CurrentUser> userProvider;
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final Project.NameKey allProjects;
@@ -87,6 +84,8 @@
   private final DateFormat rfc2822DateFormatter;
   private final Provider<GetConfig> getConfig;
   private final AccountLoader.Factory accountLoader;
+  private final BlockedNameFilter blockedNameFilter;
+  private final ProjectCache projectCache;
 
   @Inject
   CreateServiceUser(
@@ -97,28 +96,23 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       MetaDataUpdate.User metaDataUpdateFactory,
       ProjectCache projectCache,
+      AllProjectsName allProjects,
       Provider<GetConfig> getConfig,
-      AccountLoader.Factory accountLoader) {
+      AccountLoader.Factory accountLoader,
+      BlockedNameFilter blockedNameFilter) {
     this.cfg = cfgFactory.getFromGerritConfig(pluginName);
     this.createAccount = createAccount;
-    this.blockedNames =
-        Lists.transform(
-            Arrays.asList(cfg.getStringList("block")),
-            new Function<String, String>() {
-              @Override
-              public String apply(String blockedName) {
-                return blockedName.toLowerCase();
-              }
-            });
     this.userProvider = userProvider;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
     this.storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
-    this.allProjects = projectCache.getAllProjects().getProject().getNameKey();
+    this.allProjects = allProjects;
     this.rfc2822DateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
     this.rfc2822DateFormatter.setCalendar(
         Calendar.getInstance(gerritIdent.getTimeZone(), Locale.US));
     this.getConfig = getConfig;
     this.accountLoader = accountLoader;
+    this.blockedNameFilter = blockedNameFilter;
   }
 
   @Override
@@ -146,7 +140,7 @@
       throw new BadRequestException("sshKey invalid.");
     }
 
-    if (blockedNames.contains(username.toLowerCase())) {
+    if (blockedNameFilter.isBlocked(username)) {
       throw new BadRequestException(
           "The username '" + username + "' is not allowed as name for service users.");
     }
@@ -178,16 +172,20 @@
     Account.Id creatorId = ((IdentifiedUser) user).getAccountId();
     String creationDate = rfc2822DateFormatter.format(new Date());
 
-    Config db = storage.get();
-    db.setInt(USER, response.username, KEY_CREATOR_ID, creatorId.get());
-    if (creator != null) {
-      db.setString(USER, response.username, KEY_CREATED_BY, creator);
-    }
-    db.setString(USER, response.username, KEY_CREATED_AT, creationDate);
+    try {
+      Config db = storage.get();
+      db.setInt(USER, response.username, KEY_CREATOR_ID, creatorId.get());
+      if (creator != null) {
+        db.setString(USER, response.username, KEY_CREATED_BY, creator);
+      }
+      db.setString(USER, response.username, KEY_CREATED_AT, creationDate);
 
-    MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
-    md.setMessage("Create service user '" + username + "'\n");
-    storage.commit(md);
+      MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
+      md.setMessage("Create service user '" + username + "'\n");
+      storage.commit(md);
+    } finally {
+      projectCache.evict(allProjects);
+    }
     ServiceUserInfo info = new ServiceUserInfo(response);
     AccountLoader al = accountLoader.create(true);
     info.createdBy = al.get(creatorId);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
index 31c2eb6..7686d4b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetOwner.java
@@ -30,6 +30,8 @@
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.restapi.group.GroupJson;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -39,28 +41,33 @@
   private final String pluginName;
   private final ProjectCache projectCache;
   private final GroupJson json;
+  private final OneOffRequestContext requestContext;
 
   @Inject
   GetOwner(
       GroupsCollection groups,
       @PluginName String pluginName,
       ProjectCache projectCache,
-      GroupJson json) {
+      GroupJson json,
+      OneOffRequestContext requestContext) {
     this.groups = groups;
     this.pluginName = pluginName;
     this.projectCache = projectCache;
     this.json = json;
+    this.requestContext = requestContext;
   }
 
   @Override
   public Response<GroupInfo> apply(ServiceUserResource rsrc)
       throws RestApiException, PermissionBackendException {
-    ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
-    String owner = storage.get().getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
-    if (owner != null) {
-      GroupDescription.Basic group =
-          groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(owner)).getGroup();
-      return Response.<GroupInfo>ok(json.format(group));
+    try (ManualRequestContext ctx = requestContext.open()) {
+      ProjectLevelConfig storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
+      String owner = storage.get().getString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
+      if (owner != null) {
+        GroupDescription.Basic group =
+            groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(owner)).getGroup();
+        return Response.<GroupInfo>ok(json.format(group));
+      }
     }
     return Response.none();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java
index a99987b..650eba6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java
@@ -23,6 +23,6 @@
   @Override
   protected void configure() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("gr-serviceuser.html"));
+        .toInstance(new JavaScriptPlugin("serviceuser.js"));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
index 78bc448..fd8bbbf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -109,10 +110,11 @@
       db.unset(USER, rsrc.getUser().getUserName().get(), KEY_OWNER);
     } else {
       group = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(input.group)).getGroup();
-      if (!AccountGroup.isInternalGroup(group.getGroupUUID())) {
-        throw new MethodNotAllowedException();
+      UUID groupUUID = group.getGroupUUID();
+      if (!AccountGroup.uuid(groupUUID.get()).isInternalGroup()) {
+        throw new MethodNotAllowedException("Group with UUID '" + groupUUID + "' is external");
       }
-      db.setString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER, group.getGroupUUID().get());
+      db.setString(USER, rsrc.getUser().getUserName().get(), KEY_OWNER, groupUUID.get());
     }
     MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
     md.setMessage("Set owner for service user '" + rsrc.getUser().getUserName() + "'\n");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java
new file mode 100644
index 0000000..6bca1ff
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUser.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATED_AT;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATED_BY;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_CREATOR_ID;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_OWNER;
+import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.USER;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.GetServiceUser.ServiceUserInfo;
+import com.googlesource.gerrit.plugins.serviceuser.RegisterServiceUser.Input;
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@RequiresCapability(CreateServiceUserCapability.ID)
+@Singleton
+class RegisterServiceUser
+    implements RestCollectionCreateView<ConfigResource, ServiceUserResource, Input> {
+
+  static class Input {
+    String username;
+    String creator;
+    String owner;
+  }
+
+  private final AccountResolver accountResolver;
+  private final GroupResolver groupResolver;
+  private final Provider<CurrentUser> userProvider;
+  private final MetaDataUpdate.User metaDataUpdateFactory;
+  private final Project.NameKey allProjects;
+  private final ProjectLevelConfig storage;
+  private final DateFormat rfc2822DateFormatter;
+  private final AccountLoader.Factory accountLoader;
+  private final PermissionBackend permissionBackend;
+  private final BlockedNameFilter blockedNameFilter;
+  private final ProjectCache projectCache;
+
+  @Inject
+  RegisterServiceUser(
+      AccountResolver accountResolver,
+      GroupResolver groupResolver,
+      @PluginName String pluginName,
+      Provider<CurrentUser> userProvider,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      ProjectCache projectCache,
+      AllProjectsName allProjects,
+      AccountLoader.Factory accountLoader,
+      PermissionBackend permissionBackend,
+      BlockedNameFilter blockedNameFilter) {
+    this.accountResolver = accountResolver;
+    this.groupResolver = groupResolver;
+    this.userProvider = userProvider;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.storage = projectCache.getAllProjects().getConfig(pluginName + ".db");
+    this.allProjects = allProjects;
+    this.rfc2822DateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
+    this.rfc2822DateFormatter.setCalendar(
+        Calendar.getInstance(gerritIdent.getTimeZone(), Locale.US));
+    this.accountLoader = accountLoader;
+    this.permissionBackend = permissionBackend;
+    this.blockedNameFilter = blockedNameFilter;
+  }
+
+  @Override
+  public Response<ServiceUserInfo> apply(ConfigResource parentResource, IdString id, Input input)
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+    CurrentUser requestingUser = userProvider.get();
+    if (requestingUser == null || !requestingUser.isIdentifiedUser()) {
+      throw new AuthException("authentication required");
+    }
+
+    if (input == null) {
+      input = new Input();
+    }
+
+    IdentifiedUser user;
+    try {
+      user = accountResolver.resolve(input.username).asUniqueUser();
+    } catch (UnresolvableAccountException e) {
+      throw new BadRequestException("Username does not exist");
+    }
+
+    if (!requestingUser.getAccountId().equals(user.getAccountId())
+        && !permissionBackend.user(requestingUser).testOrFalse(ADMINISTRATE_SERVER)) {
+      throw new MethodNotAllowedException("Forbidden");
+    }
+
+    Config db = storage.get();
+    if (db.getSubsections(USER).contains(input.username)) {
+      return Response.none();
+    }
+
+    if (blockedNameFilter.isBlocked(input.username)) {
+      throw new BadRequestException(
+          "The username '" + input.username + "' is not allowed as name for service users.");
+    }
+
+    String creator;
+    Account.Id creatorId;
+    if (Strings.isNullOrEmpty(input.creator)) {
+      creator = requestingUser.getUserName().orElse(null);
+      creatorId = requestingUser.asIdentifiedUser().getAccountId();
+    } else {
+      creator = input.creator;
+      creatorId = accountResolver.resolve(input.creator).asUniqueUser().getAccountId();
+    }
+    String creationDate = rfc2822DateFormatter.format(new Date());
+
+    String owner = null;
+    if (!Strings.isNullOrEmpty(input.owner)) {
+      try {
+        owner = groupResolver.parse(input.owner).getGroupUUID().toString();
+      } catch (UnresolvableAccountException e) {
+        throw new BadRequestException("The group '" + input.owner + "' does not exist");
+      }
+    }
+
+    try {
+      db.setInt(USER, input.username, KEY_CREATOR_ID, creatorId.get());
+      if (creator != null) {
+        db.setString(USER, input.username, KEY_CREATED_BY, creator);
+      }
+      if (owner != null) {
+        db.setString(USER, input.username, KEY_OWNER, owner);
+      }
+      db.setString(USER, input.username, KEY_CREATED_AT, creationDate);
+
+      MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
+      md.setMessage("Create service user '" + input.username + "'\n");
+      storage.commit(md);
+    } finally {
+      projectCache.evict(allProjects);
+    }
+    ServiceUserInfo info = new ServiceUserInfo(new AccountInfo(user.getAccountId().get()));
+    AccountLoader al = accountLoader.create(true);
+    info.createdBy = al.get(creatorId);
+    al.fill();
+    info.createdAt = creationDate;
+    return Response.created(info);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java
new file mode 100644
index 0000000..fb8503c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/RegisterServiceUserCommand.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(CreateServiceUserCapability.ID)
+@CommandMetaData(name = "register", description = "Register Service User")
+class RegisterServiceUserCommand extends SshCommand {
+
+  @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the service user")
+  private String username;
+
+  @Option(
+      name = "--creator",
+      required = false,
+      metaVar = "CREATOR",
+      usage = "name of the creator of the service user")
+  private String creator;
+
+  @Option(
+      name = "--owner",
+      required = false,
+      metaVar = "OWNER",
+      usage = "group that owns the service user")
+  private String owner;
+
+  @Inject private RegisterServiceUser registerServiceUser;
+
+  @Override
+  protected void run()
+      throws IOException, UnloggedFailure, ConfigInvalidException, PermissionBackendException {
+    RegisterServiceUser.Input input = new RegisterServiceUser.Input();
+    input.username = username;
+    input.creator = creator;
+    input.owner = owner;
+
+    try {
+      registerServiceUser.apply(new ConfigResource(), IdString.fromDecoded(username), input);
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
index f775e4c..40a1509 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/SshModule.java
@@ -21,5 +21,6 @@
   @Override
   protected void configureCommands() {
     command(CreateServiceUserCommand.class);
+    command(RegisterServiceUserCommand.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ValidateServiceUserCommits.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ValidateServiceUserCommits.java
index b576b1c..6d4783e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ValidateServiceUserCommits.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ValidateServiceUserCommits.java
@@ -29,9 +29,12 @@
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class ValidateServiceUserCommits implements CommitValidationListener {
+  private static final Logger log = LoggerFactory.getLogger(ValidateServiceUserCommits.class);
   private final ServiceUserResolver serviceUserResolver;
   private final AccountCache accountCache;
 
@@ -71,6 +74,7 @@
         }
       }
     } catch (RestApiException e) {
+      log.error(e.getMessage(), e);
       throw new CommitValidationException(
           "Internal error while checking for service user commits.", e);
     }
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 17a4e29..e19d856 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -36,11 +36,11 @@
     createdAt = Wed, 13 Nov 2013 14:45:00 +0100
 ```
 
-<a id="createdBy">
+<a id="createdBy"></a>
 `user.<service-user-name>.createdBy`
 : The username of the user who created the service user.
 
-<a id="createdAt">
+<a id="createdAt"></a>
 `user.<service-user-name>.createdAt`
 : The date when the service user was created.
 
diff --git a/src/main/resources/Documentation/cmd-register.md b/src/main/resources/Documentation/cmd-register.md
new file mode 100644
index 0000000..3378a2c
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-register.md
@@ -0,0 +1,50 @@
+@PLUGIN@ register
+=================
+
+NAME
+----
+@PLUGIN@ register - Registers an existing user as a service user
+
+SYNOPSIS
+--------
+```
+ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ register
+  --creator <CREATOR>
+  --owner <OWNER>
+  <USERNAME>
+```
+
+DESCRIPTION
+-----------
+Registers an existing user as a service user.
+
+ACCESS
+------
+Caller must be a member of a group that is granted the
+'Create Service User' capability (provided by this plugin) or the
+'Administrate Server' capability. If not possessing the 'Administrate
+Server' capability, the user to be registered as a service user must
+also be the caller.
+
+SCRIPTING
+---------
+This command is intended to be used in scripts.
+
+OPTIONS
+-------
+
+`--creator`
+:   Username of the user that will be set as the creator of the
+    serviceuser. Defaults to the caller.
+
+`--owner`
+:   ID or name of the group that will own the service user. Defaults
+    to no owner group being set.
+
+EXAMPLES
+--------
+Register a service user:
+
+```
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ register --creator admin --owner Administrators username
+```
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index f52421e..aa2e4b5 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -9,30 +9,41 @@
     group = Service Users
 ```
 
-<a id="block">
+<a id="block"></a>
 `plugin.@PLUGIN@.block`
 :	A username which is forbidden to be used as name for a service
-	user. The blocked username is case insensitive. Multiple
-	usernames can be blocked by specifying multiple
+	user. The blocked username is case insensitive. The match can
+	either be exact, have a wildcard ('*') at the end or use regular
+	expressions, which have to start with '^'. If the regex pattern is not
+	ending with '$', every username starting with a matching prefix will be
+	blocked. Multiple usernames can be blocked by specifying multiple
 	`plugin.@PLUGIN@.block` entries.
+	Examples:
 
-<a id="group">
+```
+   [plugin "serviceuser"]
+        block = johndoe
+        block = jane*
+        block = ^gerrit[0-9]*
+```
+
+<a id="group"></a>
 `plugin.@PLUGIN@.group`
 :	The name of an internal group to which newly created service users
 	should be automatically added. Multiple groups can be specified by
 	having multiple `plugin.@PLUGIN@.group` entries.
 
-<a id="infoMessage">
+<a id="infoMessage"></a>
 `plugin.@PLUGIN@.infoMessage`
 :	HTML formatted message that should be displayed on the service user
 	creation screen.
 
-<a id="onSuccessMessage">
+<a id="onSuccessMessage"></a>
 `plugin.@PLUGIN@.onSuccessMessage`
 :	Message that should be displayed after a service user was
 	successfully created.
 
-<a id="allowEmail">
+<a id="allowEmail"></a>
 `plugin.@PLUGIN@.allowEmail`
 :	Whether it is allowed for service user owners to set email
 	addresses for their service users. Independent of this setting
@@ -40,7 +51,7 @@
 	any service user.
 	By default false.
 
-<a id="allowHttpPassword">
+<a id="allowHttpPassword"></a>
 `plugin.@PLUGIN@.allowHttpPassword`
 :	Whether it is allowed for service user owners to generate HTTP
     passwords for their service users. Independent of this setting
@@ -48,12 +59,12 @@
     passwords for any service user.
     By default false.
 
-<a id="allowOwner">
+<a id="allowOwner"></a>
 `plugin.@PLUGIN@.allowOwner`
 :	Whether it is allowed to set an owner group for a service user.
 	By default false.
 
-<a id="createNotes">
+<a id="createNotes"></a>
 `plugin.@PLUGIN@.createNotes`
 :	Whether commits of a service user should be annotated by a Git note
 	that contains information about the current owners of the service
@@ -62,7 +73,7 @@
 	user the 'Forge Committer' access right must be blocked for service
 	users. By default true.
 
-<a id="createNotes">
+<a id="createNotes"></a>
 `plugin.@PLUGIN@.createNotesAsync`
 :	Whether the Git notes on commits that are pushed by a service user
 	should be created asynchronously. By default false.
diff --git a/src/main/resources/static/gr-serviceuser-create.js b/src/main/resources/static/gr-serviceuser-create.js
deleted file mode 100644
index df5d9a6..0000000
--- a/src/main/resources/static/gr-serviceuser-create.js
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed 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.
- */
-
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-serviceuser-create',
-    _legacyUndefinedCheck: true,
-
-    properties: {
-      _infoMessageEnabled: {
-        type: Boolean,
-        value: false,
-      },
-      _infoMessage: String,
-      _successMessageEnabled: {
-        type: Boolean,
-        value: false,
-      },
-      _successMessage: String,
-      _newUsername: String,
-      _emailEnabled: {
-        type: Boolean,
-        value: false,
-      },
-      _newEmail: String,
-      _newKey: String,
-      _dataValid: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdding: {
-        type: Boolean,
-        value: false,
-      },
-      _enableButton: {
-        type: Boolean,
-        value: false,
-      },
-      _accountId: String,
-    },
-
-    attached() {
-      this._getConfig();
-    },
-
-    _forwardToDetails() {
-      page.show(
-          this.plugin.screenUrl()
-          + '/user/'
-          + this._accountId);
-    },
-
-    _getConfig() {
-      return this.plugin.restApi('/config/server/serviceuser~config/').get('')
-          .then(config => {
-            if (!config) {
-              return;
-            }
-
-            if (config.info && config.info != '') {
-              this._infoMessageEnabled = true;
-              this._infoMessage = config.info;
-              this.$.infoMessage.innerHTML = this._infoMessage;
-            }
-
-            if (config.on_success && config.on_success != '') {
-              this._successMessageEnabled = true;
-              this._successMessage = config.on_success;
-              this.$.successMessage.innerHTML = this._successMessage;
-            }
-
-            this._emailEnabled = config.allow_email;
-          });
-    },
-
-    _validateData() {
-      this._dataValid = this._validateName(this._newUsername)
-        && this._validateEmail(this._newEmail)
-        && this._validateKey(this._newKey);
-      this._computeButtonEnabled();
-    },
-
-    _validateName(username) {
-      if (username && username.trim().length > 0) {
-        return true;
-      }
-
-      return false;
-    },
-
-    _validateEmail(email) {
-      if (!email || email.trim().length == 0 || email.includes('@')) {
-        return true;
-      }
-
-      return false;
-    },
-
-    _validateKey(key) {
-      if (!key || !key.trim()) {
-        return false;
-      }
-
-      return true;
-    },
-
-    _computeButtonEnabled() {
-      this._enableButton = this._dataValid && !this._isAdding;
-    },
-
-    _handleCreateServiceUser() {
-      this._isAdding = true;
-      this._computeButtonEnabled();
-      const body = {
-        ssh_key: this._newKey.trim(),
-        email: this._newEmail ? this._newEmail.trim() : null,
-      };
-      return this.plugin.restApi('/config/server/serviceuser~serviceusers/')
-          .post(this._newUsername, body)
-          .then(response => {
-            this._accountId = response._account_id;
-            if (this._successMessage) {
-              this.$.successDialogOverlay.open();
-            } else {
-              this._forwardToDetails();
-            }
-          }).catch(response => {
-            this.fire('show-error', {message: response});
-            this._isAdding = false;
-            this._computeButtonEnabled();
-          });
-    },
-  });
-})();
diff --git a/src/main/resources/static/gr-serviceuser-detail.js b/src/main/resources/static/gr-serviceuser-detail.js
deleted file mode 100644
index 95aded2..0000000
--- a/src/main/resources/static/gr-serviceuser-detail.js
+++ /dev/null
@@ -1,345 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed 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.
-
-(function() {
-  'use strict';
-
-  const NOT_FOUND_MESSAGE = 'Not Found';
-
-  Polymer({
-    is: 'gr-serviceuser-detail',
-    _legacyUndefinedCheck: true,
-
-    properties: {
-      _restApi: Object,
-      _serviceUserId: String,
-      _serviceUser: Object,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _statusButtonText: {
-        type: String,
-        value: 'Activate',
-      },
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _changingPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _allowEmail: {
-        type: Boolean,
-        value: false,
-      },
-      _allowOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _allowHttpPassword: {
-        type: Boolean,
-        value: false,
-      },
-      _newFullName: String,
-      _newEmail: String,
-      _availableOwners: Array,
-      _newOwner: String,
-      _ownerChangeWarning: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
-        },
-      },
-    },
-
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
-
-    attached() {
-      this._extractUserId();
-      this._loadServiceUser();
-    },
-
-    _loadServiceUser() {
-      if (!this._serviceUserId) { return; }
-
-      const promises = [];
-
-      promises.push(this._getPluginConfig());
-      promises.push(this._getServiceUser());
-
-      Promise.all(promises).then(() => {
-        this.$.sshEditor.loadData(this._restApi, this._serviceUser);
-        this.$.httpPass.loadData(this._restApi, this._serviceUser);
-
-        this.fire('title-change', {title: this._serviceUser.name});
-        this._computeStatusButtonText();
-        this._loading = false;
-        this._newFullName = this._serviceUser.name;
-        this._newEmail = this._serviceUser.email;
-      });
-    },
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    _extractUserId() {
-      this._serviceUserId = this.baseURI.split('/').pop();
-    },
-
-    _getPermissions() {
-      return this.plugin.restApi('/accounts/self/capabilities/').get('')
-          .then(capabilities => {
-            this._isAdmin = capabilities && capabilities.administrateServer;
-          });
-    },
-
-    _getPluginConfig() {
-      return Promise.resolve(this._getPermissions()).then(() => {
-        this.plugin.restApi('/config/server/serviceuser~config/').get('')
-            .then(config => {
-              if (!config) {
-                return;
-              }
-              this._allowEmail = config.allow_email || this._isAdmin;
-              this._allowOwner = config.allow_owner || this._isAdmin;
-              this._allowHttpPassword = config.allow_http_password
-                || this._isAdmin;
-            });
-      });
-    },
-
-    _getServiceUser() {
-      this._restApi = this.plugin.restApi(
-          '/config/server/serviceuser~serviceusers/');
-      return this._restApi.get(this._serviceUserId)
-          .then(serviceUser => {
-            if (!serviceUser) {
-              this._serviceUser = {};
-              return;
-            }
-            this._serviceUser = serviceUser;
-          });
-    },
-
-    _active(serviceUser) {
-      if (!serviceUser) {
-        return NOT_FOUND_MESSAGE;
-      }
-
-      return serviceUser.inactive === true ? 'Inactive' : 'Active';
-    },
-
-    _computeStatusButtonText() {
-      if (!this._serviceUser) {
-        return;
-      }
-
-      this._statusButtonText = this._serviceUser.inactive === true
-        ? 'Activate'
-        : 'Deactivate';
-    },
-
-    _toggleStatus() {
-      if (this._serviceUser.inactive === true) {
-        this._restApi.put(`${this._serviceUser._account_id}/active`)
-            .then(() => {
-              this._loadServiceUser();
-            });
-      } else {
-        this._restApi.delete(`${this._serviceUser._account_id}/active`)
-            .then(() => {
-              this._loadServiceUser();
-            });
-      }
-    },
-
-    _getCreator(serviceUser) {
-      if (!serviceUser || !serviceUser.created_by) {
-        return NOT_FOUND_MESSAGE;
-      }
-
-      if (serviceUser.created_by.username != undefined) {
-        return serviceUser.created_by.username;
-      }
-
-      if (serviceUser.created_by._account_id != -1) {
-        return serviceUser.created_by._account_id;
-      }
-
-      return NOT_FOUND_MESSAGE;
-    },
-
-    _getOwnerGroup(serviceUser) {
-      return serviceUser && serviceUser.owner
-        ? serviceUser.owner.name
-        : NOT_FOUND_MESSAGE;
-    },
-
-    _isEmailValid(email) {
-      if (!email) {
-        return false;
-      }
-      return email.includes('@');
-    },
-
-    _getGroupSuggestions(input) {
-      let query;
-      if (!input || input === this._getOwnerGroup(this._serviceUser)) {
-        query = '';
-      } else {
-        query = `?suggest=${input}`;
-      }
-
-      return this.plugin.restApi('/a/groups/').get(query)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
-            }
-            this._availableOwners = groups;
-            return groups;
-          });
-    },
-
-    _isOwnerValid(owner) {
-      if (!owner) {
-        return false;
-      }
-
-      return this._getOwnerName(owner);
-    },
-
-    _isNewOwner() {
-      return this._getOwnerName(this._newOwner)
-         === this._getOwnerGroup(this._serviceUser);
-    },
-
-    _getOwnerName(id) {
-      return this._availableOwners.find(o => { return o.value === id; }).name;
-    },
-
-    _computeOwnerWarning() {
-      let message = 'If ';
-      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
-        ? 'the owner group is changed' : 'an owner group is set';
-      message += ' only members of the ';
-      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
-        ? 'new ' : '';
-      message += 'owner group can see and administrate the service user.';
-      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
-        ? '' : ' The creator of the service user can no'
-          + ' longer see and administrate the service user if she/he'
-          + ' is not member of the owner group.';
-      this._ownerChangeWarning = message;
-    },
-
-    _computePrefsChanged() {
-      if (this.loading || this._changingPrefs) {
-        return;
-      }
-
-      if (!this._newOwner && !this._newEmail && !this._newFullName) {
-        this._prefsChanged = false;
-        return;
-      }
-
-      if (this._newEmail && !this._isEmailValid(this._newEmail)) {
-        this._prefsChanged = false;
-        return;
-      }
-
-      if (this._newOwner
-          && (this._isNewOwner() || !this._isOwnerValid(this._newOwner))) {
-        this._prefsChanged = false;
-        return;
-      }
-
-      if (this._newOwner) {
-        this._computeOwnerWarning();
-      }
-
-      this._prefsChanged = true;
-    },
-
-    _applyNewFullName() {
-      return this._restApi
-          .put(`${this._serviceUser._account_id}/name`,
-               {name: this._newFullName})
-          .then(() => {
-            this.$.serviceUserFullNameInput.value = '';
-          });
-    },
-
-    _applyNewEmail(email) {
-      if (!this._isEmailValid(email)) {
-        return;
-      }
-      return this._restApi
-          .put(`${this._serviceUser._account_id}/email`, {email})
-          .then(() => {
-            this.$.serviceUserEmailInput.value = '';
-          });
-    },
-
-    _applyNewOwner(owner) {
-      if (this._isNewOwner() || !this._isOwnerValid(this._newOwner)) {
-        return;
-      }
-      return this._restApi
-          .put(`${this._serviceUser._account_id}/owner`, {group: owner})
-          .then(() => {
-            this.$.serviceUserOwnerInput.text = this._getOwnerGroup(
-                this._serviceUser);
-          });
-    },
-
-    _handleSavePreferences() {
-      const promises = [];
-      this._changingPrefs = true;
-
-      if (this._newFullName) {
-        promises.push(this._applyNewFullName());
-      }
-
-      if (this._newEmail) {
-        promises.push(this._applyNewEmail(this._newEmail));
-      }
-
-      if (this._newOwner) {
-        promises.push(this._applyNewOwner(this._newOwner));
-      }
-
-      Promise.all(promises).then(() => {
-        this._changingPrefs = false;
-        this._prefsChanged = false;
-        this._ownerChangeWarning = '';
-        this._loadServiceUser();
-      });
-    },
-  });
-})();
diff --git a/src/main/resources/static/gr-serviceuser-http-password.js b/src/main/resources/static/gr-serviceuser-http-password.js
deleted file mode 100644
index 87e1543..0000000
--- a/src/main/resources/static/gr-serviceuser-http-password.js
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed 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.
- */
-
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-serviceuser-http-password',
-    _legacyUndefinedCheck: true,
-
-    properties: {
-      _restApi: Object,
-      _serviceUser: Object,
-      _generatedPassword: String,
-      _passwordUrl: String,
-    },
-
-    loadData(restApi, serviceUser) {
-      this._restApi = restApi;
-      this._serviceUser = serviceUser;
-    },
-
-    _handleGenerateTap() {
-      this._generatedPassword = 'Generating...';
-      this.$.generatedPasswordOverlay.open();
-      this._restApi
-          .put(`${this._serviceUser._account_id}/password.http`,
-               {generate: true})
-          .then(newPassword => {
-            this._generatedPassword = newPassword;
-          });
-    },
-
-    _closeOverlay() {
-      this.$.generatedPasswordOverlay.close();
-    },
-
-    _generatedPasswordOverlayClosed() {
-      this._generatedPassword = '';
-    },
-
-    _handleDelete() {
-      this._restApi.delete(`${this._serviceUser._account_id}/password.http`);
-    },
-  });
-})();
diff --git a/src/main/resources/static/gr-serviceuser-list.js b/src/main/resources/static/gr-serviceuser-list.js
deleted file mode 100644
index 8f35dd4..0000000
--- a/src/main/resources/static/gr-serviceuser-list.js
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed 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.
-
-(function() {
-  'use strict';
-
-  const NOT_FOUND_MESSAGE = 'Not Found';
-
-  Polymer({
-    is: 'gr-serviceuser-list',
-    _legacyUndefinedCheck: true,
-
-    properties: {
-      _serviceUsers: Array,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-    },
-
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
-
-    attached() {
-      this.fire('title-change', {title: 'Service Users'});
-      this._getServiceUsers();
-    },
-
-    _getServiceUsers() {
-      return this.plugin.restApi('/config/server/serviceuser~serviceusers/')
-          .get('')
-          .then(serviceUsers => {
-            if (!serviceUsers) {
-              this._serviceUsers = [];
-              return;
-            }
-            this._serviceUsers = Object.keys(serviceUsers)
-            .map(key => {
-              const serviceUser = serviceUsers[key];
-              serviceUser.username = key;
-              return serviceUser;
-            });
-            this._loading = false;
-          });
-    },
-
-    _active(item) {
-      if (!item) {
-        return NOT_FOUND_MESSAGE;
-      }
-
-      return item.inactive === true ? 'Inactive' : 'Active';
-    },
-
-    _getCreator(item) {
-      if (!item || !item.created_by) {
-        return NOT_FOUND_MESSAGE;
-      }
-
-      if (item.created_by.username != undefined) {
-        return item.created_by.username;
-      }
-
-      if (item.created_by._account_id != -1) {
-        return item.created_by._account_id;
-      }
-
-      return NOT_FOUND_MESSAGE;
-    },
-
-    _getOwnerGroup(item) {
-      return item && item.owner ? item.owner.name : NOT_FOUND_MESSAGE;
-    },
-
-    _computeServiceUserUrl(id) {
-      return `${this.plugin.screenUrl()}/user/${id}`;
-    },
-
-    _createNewServiceUser() {
-      page.show(this.plugin.screenUrl() + '/create');
-    },
-  });
-})();
diff --git a/src/main/resources/static/gr-serviceuser-ssh-panel.js b/src/main/resources/static/gr-serviceuser-ssh-panel.js
deleted file mode 100644
index e1e8dd0..0000000
--- a/src/main/resources/static/gr-serviceuser-ssh-panel.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed 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.
- */
-
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-serviceuser-ssh-panel',
-    _legacyUndefinedCheck: true,
-
-    properties: {
-      _restApi: Object,
-      _serviceUser: Object,
-      _keys: Array,
-      /** @type {?} */
-      _keyToView: Object,
-      _newKey: {
-        type: String,
-        value: '',
-      },
-      _keysToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-    },
-
-    loadData(restApi, serviceUser) {
-      this._restApi = restApi;
-      this._serviceUser = serviceUser;
-      return this._restApi.get(`${this._serviceUser._account_id}/sshkeys`)
-          .then(keys => {
-            if (!keys) {
-              this._keys = [];
-              return;
-            }
-            this._keys = keys;
-          });
-    },
-
-    _getStatusLabel(isValid) {
-      return isValid ? 'Valid' : 'Invalid';
-    },
-
-    _showKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this._keyToView = this._keys[index];
-      this.$.viewKeyOverlay.open();
-    },
-
-    _closeOverlay() {
-      this.$.viewKeyOverlay.close();
-    },
-
-    _handleDeleteKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this.push('_keysToRemove', this._keys[index]);
-
-      const promises = this._keysToRemove.map(key => {
-        this._restApi.delete(`${this._serviceUser._account_id}/sshkeys/${key.seq}`);
-      });
-
-      return Promise.all(promises).then(() => {
-        this.splice('_keys', index, 1);
-        this._keysToRemove = [];
-      });
-    },
-
-    _handleAddKey() {
-      this.$.addButton.disabled = true;
-      this.$.newKey.disabled = true;
-      return this._restApi.post(`${this._serviceUser._account_id}/sshkeys`,
-          this._newKey.trim(), null, 'plain/text')
-        .then(key => {
-          this.push('_keys', key);
-        }).catch(() => {
-          this.$.addButton.disabled = false;
-          this.$.newKey.disabled = false;
-        });
-    },
-
-    _computeAddButtonDisabled(newKey) {
-      return !newKey.length;
-    },
-  });
-})();
diff --git a/src/main/resources/static/gr-serviceuser.html b/src/main/resources/static/gr-serviceuser.html
deleted file mode 100644
index 92714a2..0000000
--- a/src/main/resources/static/gr-serviceuser.html
+++ /dev/null
@@ -1,46 +0,0 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed 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.
--->
-
-
-<link rel="import"
-      href="./gr-serviceuser-list.html">
-<link rel="import"
-      href="./gr-serviceuser-detail.html">
-<link rel="import"
-      href="./gr-serviceuser-create.html">
-
-<dom-module id="gr-serviceuser">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.restApi('/accounts/self/capabilities/').get('')
-          .then(capabilities => {
-            if (capabilities
-                && (capabilities.administrateServer
-                    || capabilities['serviceuser-createServiceUser'])) {
-              plugin.screen('list', 'gr-serviceuser-list');
-              plugin.screen('user', 'gr-serviceuser-detail');
-              plugin.screen('create', 'gr-serviceuser-create');
-            }
-            plugin.admin()
-              .addMenuLink(
-                'Service Users',
-                '/x/serviceuser/list',
-                'serviceuser-createServiceUser');
-          });
-    });
-  </script>
-</dom-module>
diff --git a/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java
new file mode 100644
index 0000000..997c275
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/serviceuser/BlockedNameFilterTest.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BlockedNameFilterTest {
+
+  private static String[] BLOCKED_NAMES =
+      new String[] {
+        "exact", "ex*act", "wild*", "^regex[0-9]+", "^ABC", "^[0-9]+$", "regex[0-9]+", "^⁋+"
+      };
+
+  private BlockedNameFilter blockedNameFilter;
+
+  @Mock private PluginConfigFactory configFactory;
+
+  @Mock private PluginConfig config;
+
+  @Before
+  public void setup() {
+    when(configFactory.getFromGerritConfig("serviceuser")).thenReturn(config);
+    when(config.getStringList("block")).thenReturn(BLOCKED_NAMES);
+    blockedNameFilter = new BlockedNameFilter(configFactory, "serviceuser");
+  }
+
+  @Test
+  public void exactMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("exact")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ExAct")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ex*act")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("regex[0-9]+")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("notexact")).isFalse();
+    assertThat(blockedNameFilter.isBlocked("exxact")).isFalse();
+  }
+
+  @Test
+  public void wildcardMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("wild")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("wildcard")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("Wilde")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("wil")).isFalse();
+  }
+
+  @Test
+  public void regexMatchIsBlocked() {
+    assertThat(blockedNameFilter.isBlocked("regex1")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("Regex1")).isTrue();
+
+    // Pattern matching is done at the beginning of the username
+    assertThat(blockedNameFilter.isBlocked("foo-regex1")).isFalse();
+
+    // Names with unicode characters can be blocked
+    assertThat(blockedNameFilter.isBlocked("⁋")).isTrue();
+
+    // Regex matches only complete name, when ending with '$'.
+    assertThat(blockedNameFilter.isBlocked("01234")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("01234abcd")).isFalse();
+
+    // Regex matches prefix without trailing '$'
+    assertThat(blockedNameFilter.isBlocked("regex1suffix")).isTrue();
+
+    // Uppercase regex matches case-insenstive
+    assertThat(blockedNameFilter.isBlocked("abc")).isTrue();
+    assertThat(blockedNameFilter.isBlocked("ABC")).isTrue();
+  }
+}
diff --git a/tools/BUILD b/tools/BUILD
deleted file mode 100644
index cc10083..0000000
--- a/tools/BUILD
+++ /dev/null
@@ -1 +0,0 @@
-# Empty file - bazel treat directories with BUILD file as a package
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
deleted file mode 100644
index c5ed0b7..0000000
--- a/tools/bzl/BUILD
+++ /dev/null
@@ -1 +0,0 @@
-# Empty file required by Bazel
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
deleted file mode 100644
index c921d01..0000000
--- a/tools/bzl/classpath.bzl
+++ /dev/null
@@ -1,6 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//tools:classpath.bzl",
-    _classpath_collector = "classpath_collector",
-)
-
-classpath_collector = _classpath_collector
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
deleted file mode 100644
index 240c448..0000000
--- a/tools/bzl/junit.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
-    _junit_tests = "junit_tests",
-)
-junit_tests = _junit_tests
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
deleted file mode 100644
index 35ea8ce..0000000
--- a/tools/bzl/maven_jar.bzl
+++ /dev/null
@@ -1,3 +0,0 @@
-load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", _maven_jar = "maven_jar")
-
-maven_jar = _maven_jar
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
deleted file mode 100644
index 4d2dbdd..0000000
--- a/tools/bzl/plugin.bzl
+++ /dev/null
@@ -1,10 +0,0 @@
-load(
-    "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
-    _gerrit_plugin = "gerrit_plugin",
-    _plugin_deps = "PLUGIN_DEPS",
-    _plugin_test_deps = "PLUGIN_TEST_DEPS",
-)
-
-gerrit_plugin = _gerrit_plugin
-PLUGIN_DEPS = _plugin_deps
-PLUGIN_TEST_DEPS = _plugin_test_deps
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
deleted file mode 100644
index a38b222..0000000
--- a/tools/eclipse/BUILD
+++ /dev/null
@@ -1,10 +0,0 @@
-load("//tools/bzl:classpath.bzl", "classpath_collector")
-load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS")
-
-classpath_collector(
-    name = "main_classpath_collect",
-    testonly = 1,
-    deps = PLUGIN_DEPS + [
-        "//:serviceuser__plugin",
-    ],
-)
diff --git a/tools/eclipse/project.sh b/tools/eclipse/project.sh
deleted file mode 100755
index 79282cf..0000000
--- a/tools/eclipse/project.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-# Copyright (C) 2018 The Android Open Source Project
-#
-# Licensed 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.
-`bazel query @com_googlesource_gerrit_bazlets//tools/eclipse:project --output location | sed s/BUILD:.*//`project.py -n serviceuser -r .
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
deleted file mode 100644
index 8574d17..0000000
--- a/tools/workspace_status.py
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/usr/bin/env python
-
-# This script will be run by bazel when the build process starts to
-# generate key-value information that represents the status of the
-# workspace. The output should be like
-#
-# KEY1 VALUE1
-# KEY2 VALUE2
-#
-# If the script exits with non-zero code, it's considered as a failure
-# and the output will be discarded.
-
-from __future__ import print_function
-import subprocess
-import sys
-
-CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
-
-
-def revision():
-    try:
-        return subprocess.check_output(CMD).strip().decode("utf-8")
-    except OSError as err:
-        print('could not invoke git: %s' % err, file=sys.stderr)
-        sys.exit(1)
-    except subprocess.CalledProcessError as err:
-        print('error using git: %s' % err, file=sys.stderr)
-        sys.exit(1)
-
-
-print("STABLE_BUILD_SERVICEUSER_LABEL %s" % revision())