Convert UI to use Typescript and Lit

Change-Id: I67217f360022d8443424982708ba3276dffa7177
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index e69de29..0000000
--- a/.eslintignore
+++ /dev/null
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index b586e29..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,166 +0,0 @@
-{
-  "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 .*;$"
-      }
-    ],
-    "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 a94d0d2..3dce633 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
 /.settings/org.eclipse.m2e.core.prefs
 /.idea
 /.apt_generated/
+/node_modules
diff --git a/BUILD b/BUILD
index d4074e0..7aaa331 100644
--- a/BUILD
+++ b/BUILD
@@ -1,6 +1,5 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS", "PLUGIN_TEST_DEPS", "gerrit_plugin")
-load("//tools/js:eslint.bzl", "eslint")
 
 gerrit_plugin(
     name = "serviceuser",
@@ -11,7 +10,7 @@
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.serviceuser.HttpModule",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.serviceuser.SshModule",
     ],
-    resource_jars = ["//plugins/serviceuser/gr-serviceuser:serviceuser"],
+    resource_jars = ["//plugins/serviceuser/web:serviceuser"],
     resources = glob(["src/main/resources/**/*"]),
 )
 
@@ -26,24 +25,3 @@
         ":serviceuser__plugin",
     ],
 )
-
-# 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/gr-serviceuser/BUILD b/gr-serviceuser/BUILD
deleted file mode 100644
index 484aafa..0000000
--- a/gr-serviceuser/BUILD
+++ /dev/null
@@ -1,14 +0,0 @@
-load("//tools/bzl:js.bzl", "gerrit_js_bundle")
-
-package(default_visibility = [":visibility"])
-
-package_group(
-    name = "visibility",
-    packages = ["//plugins/serviceuser/..."],
-)
-
-gerrit_js_bundle(
-    name = "serviceuser",
-    srcs = glob(["*.js"]),
-    entry_point = "gr-serviceuser.js",
-)
diff --git a/gr-serviceuser/gr-serviceuser-create.js b/gr-serviceuser/gr-serviceuser-create.js
deleted file mode 100644
index fa5144b..0000000
--- a/gr-serviceuser/gr-serviceuser-create.js
+++ /dev/null
@@ -1,168 +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.
- */
-
-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() {
-    window.location.href = `${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/gr-serviceuser/gr-serviceuser-create_html.js b/gr-serviceuser/gr-serviceuser-create_html.js
deleted file mode 100644
index 2a9823b..0000000
--- a/gr-serviceuser/gr-serviceuser-create_html.js
+++ /dev/null
@@ -1,96 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles"></style>
-    <style include="gr-font-styles"></style>
-    <style include="gr-form-styles"></style>
-    <style>
-      main {
-        margin: 2em auto;
-        max-width: 50em;
-      }
-
-      .heading {
-        font-size: x-large;
-        font-weight: 500;
-      }
-    </style>
-    <main class="gr-form-styles read-only">
-      <div class="topHeader">
-        <h1 class="heading">Create Service User</h1>
-      </div>
-      <fieldset id="infoMessage"
-           hidden$="[[!_infoMessageEnabled]]">
-      </fieldset>
-      <fieldset>
-        <section>
-          <span class="title">Username</span>
-          <span class="value">
-            <iron-input bind-value="{{_newUsername}}">
-              <input id="serviceUserNameInput"
-                     value="{{_newUsername::input}}"
-                     type="text"
-                     on-keyup="_validateData">
-            </iron-input>
-          </span>
-        </section>
-        <section hidden$="[[!_emailEnabled]]">
-          <span class="title">Email</span>
-          <span class="value">
-            <iron-input bind-value="{{_newEmail}}">
-              <input id="serviceUserEmailInput"
-                     value="{{_newEmail::input}}"
-                     type="text"
-                     on-keyup="_validateData">
-            </iron-input>
-          </span>
-        </section>
-      </fieldset>
-      <fieldset>
-        <section>
-          <span class="title">Public SSH key</span>
-          <span class="value">
-            <iron-autogrow-textarea id="newKey"
-                                    bind-value="{{_newKey}}"
-                                    placeholder="New SSH Key"
-                                    on-keyup="_validateData">
-            </iron-autogrow-textarea>
-          </span>
-        </section>
-      </fieldset>
-      <gr-button id="createButton"
-                 on-click="_handleCreateServiceUser"
-                 disabled="[[!_enableButton]]">
-        Create
-      </gr-button>
-      <gr-overlay id="successDialogOverlay" with-backdrop>
-        <gr-dialog id="successDialog"
-                   confirm-label="OK"
-                   cancel-label=""
-                   on-confirm="_forwardToDetails"
-                   confirm-on-enter>
-          <div slot="header">
-            Success
-          </div>
-          <div id="successMessage" slot="main">
-          </div>
-        </gr-dialog>
-      </gr-overlay>
-    </main>
-`;
diff --git a/gr-serviceuser/gr-serviceuser-detail.js b/gr-serviceuser/gr-serviceuser-detail.js
deleted file mode 100644
index c757b0c..0000000
--- a/gr-serviceuser/gr-serviceuser-detail.js
+++ /dev/null
@@ -1,373 +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.
- */
-
-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('/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(
-        '/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/gr-serviceuser/gr-serviceuser-detail_html.js b/gr-serviceuser/gr-serviceuser-detail_html.js
deleted file mode 100644
index e69cf9b..0000000
--- a/gr-serviceuser/gr-serviceuser-detail_html.js
+++ /dev/null
@@ -1,169 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles"></style>
-    <style include="gr-font-styles"></style>
-    <style include="gr-form-styles"></style>
-    <style>
-      .heading {
-        font-size: x-large;
-        font-weight: 500;
-      }
-
-      div.serviceuser-detail {
-        margin: 2em auto;
-        max-width: 50em;
-      }
-
-      h1#Title {
-        margin-bottom: 1em;
-      }
-
-      p#ownerChangeWarning {
-        margin-top: 1em;
-        margin-bottom: 1em;
-      }
-
-      span#gr_serviceuser_activity {
-        border-radius: 1em;
-        width: 10em;
-        padding: 0.3em;
-        font-weight: bold;
-        text-align: center;
-      }
-
-      span.value {
-        width: 50%;
-      }
-
-      input.wide {
-        width: 100%;
-      }
-
-      span.Active {
-        background-color: #9fcc6b;
-      }
-
-      span.Inactive {
-        background-color: #f7a1ad;
-      }
-    </style>
-    <div class="serviceuser-detail">
-      <main class="gr-form-styles read-only">
-        <div id="loading"
-             class$="[[_computeLoadingClass(_loading)]]">
-          Loading...
-        </div>
-        <div id="loadedContent"
-             class$="[[_computeLoadingClass(_loading)]]">
-          <h1 id="Title" class="heading">Service User "[[_serviceUser.name]]"</h1>
-          <div id="form">
-            <fieldset>
-              <fieldset>
-                <h2 id="accountState" class="heading-2">Account State</h2>
-                <section>
-                  <span class="title">Current State</span>
-                  <span id="gr_serviceuser_activity"
-                        class$="value [[_active(_serviceUser)]]">
-                    [[_active(_serviceUser)]]
-                  </span>
-                </section>
-                <gr-button id="statusToggleButton" on-click="_toggleStatus" disabled="[[_loading]]">
-                  [[_statusButtonText]]</gr-button>
-              </fieldset>
-              <fieldset>
-                <h2 id="userDataHeader" class="heading-2">User Data</h2>
-                <section>
-                  <span class="title">Username</span>
-                  <span class="value">[[_serviceUser.username]]</span>
-                </section>
-                <section>
-                  <span class="title">Full Name</span>
-                  <span class="value" hidden$="[[!_allowFullName]]">
-                    <iron-input bind-value="{{_newFullName}}">
-                      <input id="serviceUserFullNameInput" class="wide" value="{{_newFullName::input}}"
-                             type="text" disabled$="[[_changingPrefs]]"
-                             placeholder$="[[_serviceUser.name]]"
-                             on-keyup="_computePrefsChanged">
-                    </iron-input>
-                  </span>
-                </section>
-                <section>
-                  <span class="title">Email Address</span>
-                  <span class="value" hidden$="[[!_allowEmail]]">
-                    <iron-input bind-value="{{_newEmail}}">
-                      <input id="serviceUserEmailInput" class="wide" value="{{_newEmail::input}}"
-                            type="text" disabled$="[[_changingPrefs]]"
-                            placeholder="[[_serviceUser.email]]" on-keyup="_computePrefsChanged"
-                            hidden$="[[!_allowEmail]]">
-                    </iron-input>
-                  </span>
-                  <span class="value" hidden$="[[_allowEmail]]">[[_serviceUser.email]]</span>
-                </section>
-                <section>
-                  <span class="title">Owner Group</span>
-                  <span class="value" hidden$="[[!_allowOwner]]">
-                    <gr-autocomplete id="serviceUserOwnerInput" text="{{_getOwnerGroup(_serviceUser)}}"
-                      value="{{_newOwner}}" query="[[_query]]" disabled="[[_changingPrefs]]"
-                      on-commit="_computePrefsChanged" on-keyup="_computePrefsChanged">
-                        [[_getOwnerGroup(_serviceUser)]]
-                    </gr-autocomplete>
-                  </span>
-                  <span class="value" hidden$="[[_allowOwner]]">[[_getOwnerGroup(_serviceUser)]]</span>
-                </section>
-                <p id="ownerChangeWarning" class="style-scope gr-settings-view" hidden$="[[!_newOwner]]">
-                  [[_ownerChangeWarning]]
-                </p>
-                <gr-button id="savePrefs" on-click="_handleSavePreferences" disabled="[[!_prefsChanged]]">
-                  Save changes
-                </gr-button>
-              </fieldset>
-              <fieldset>
-                <h2 id="creationHeader" class="heading-2">Creation</h2>
-                <section>
-                  <span class="title">Created By</span>
-                  <span class="value">[[_getCreator(_serviceUser)]]</span>
-                </section>
-                <section>
-                  <span class="title">Created At</span>
-                  <span class="value">[[_serviceUser.created_at]]</span>
-                </section>
-              </fieldset>
-              <fieldset>
-                <fieldset>
-                  <h2 id="credentialsHeader" class="heading-2">Credentials</h2>
-                </fieldset>
-                <fieldset hidden$="[[!_allowHttpPassword]]">
-                  <h3 id="HTTPCredentials">HTTP Credentials</h3>
-                  <fieldset>
-                    <gr-serviceuser-http-password id="httpPass">
-                      </gr-http-password>
-                  </fieldset>
-                </fieldset>
-                <fieldset>
-                  <h3 id="SSHKeys">SSH keys</h3>
-                  <gr-serviceuser-ssh-panel id="sshEditor"></gr-serviceuser-ssh-panel>
-                </fieldset>
-              </fieldset>
-            </fieldset>
-          </div>
-        </div>
-      </main>
-    </div>
-`;
diff --git a/gr-serviceuser/gr-serviceuser-http-password.js b/gr-serviceuser/gr-serviceuser-http-password.js
deleted file mode 100644
index 396eef7..0000000
--- a/gr-serviceuser/gr-serviceuser-http-password.js
+++ /dev/null
@@ -1,71 +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.
- */
-
-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/gr-serviceuser/gr-serviceuser-http-password_html.js b/gr-serviceuser/gr-serviceuser-http-password_html.js
deleted file mode 100644
index b48855c..0000000
--- a/gr-serviceuser/gr-serviceuser-http-password_html.js
+++ /dev/null
@@ -1,78 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles">
-      .password {
-        font-family: var(--monospace-font-family);
-      }
-
-      #generatedPasswordOverlay {
-        padding: 2em;
-        width: 50em;
-      }
-
-      #generatedPasswordDisplay {
-        margin: 1em 0;
-      }
-
-      #generatedPasswordDisplay .value {
-        font-family: var(--monospace-font-family);
-      }
-
-      #passwordWarning {
-        font-style: italic;
-        text-align: center;
-      }
-
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="gr-form-styles">
-      <div>
-        <section>
-          <span class="title">Username</span>
-          <span class="value">[[_serviceUser.username]]</span>
-        </section>
-        <gr-button id="generateButton"
-                   on-click="_handleGenerateTap">Generate new password</gr-button>
-        <gr-button id="deleteButton"
-                   on-click="_handleDelete">Delete password</gr-button>
-      </div>
-    </div>
-    <gr-overlay id="generatedPasswordOverlay"
-                on-iron-overlay-closed="_generatedPasswordOverlayClosed"
-                with-backdrop>
-      <div class="gr-form-styles">
-        <section id="generatedPasswordDisplay">
-          <span class="title">New Password:</span>
-          <span class="value">[[_generatedPassword]]</span>
-        </section>
-        <section id="passwordWarning">
-          This password will not be displayed again.<br>
-          If you lose it, you will need to generate a new one.
-        </section>
-        <gr-button link
-                   class="closeButton"
-                   on-click="_closeOverlay">Close</gr-button>
-      </div>
-    </gr-overlay>
-`;
diff --git a/gr-serviceuser/gr-serviceuser-list.js b/gr-serviceuser/gr-serviceuser-list.js
deleted file mode 100644
index b579164..0000000
--- a/gr-serviceuser/gr-serviceuser-list.js
+++ /dev/null
@@ -1,133 +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.
- */
-
-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 {
-      _canCreate: {
-        type: Boolean,
-        value: false,
-      },
-      _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._getPermissions();
-    this._getServiceUsers();
-  }
-
-  _getPermissions() {
-    return this.plugin.restApi('/accounts/self/capabilities/').get('')
-        .then(capabilities => {
-          this._canCreate = capabilities
-              && (capabilities.administrateServer
-                  || capabilities['serviceuser-createServiceUser']);
-        });
-  }
-
-  _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() {
-    window.location.href = `${this.plugin.screenUrl()}/create`;
-  }
-}
-
-customElements.define(GrServiceUserList.is, GrServiceUserList);
diff --git a/gr-serviceuser/gr-serviceuser-list_html.js b/gr-serviceuser/gr-serviceuser-list_html.js
deleted file mode 100644
index c69b4ff..0000000
--- a/gr-serviceuser/gr-serviceuser-list_html.js
+++ /dev/null
@@ -1,87 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-font-styles"></style>
-    <style include="gr-table-styles"></style>
-    <style>
-      .topHeader {
-        padding: 8px;
-      }
-
-      .heading {
-        font-size: x-large;
-        font-weight: 500;
-      }
-
-      #topContainer {
-        align-items: center;
-        display: flex;
-        height: 3rem;
-        justify-content: space-between;
-        margin: 0 1em;
-      }
-    </style>
-    <div id="topContainer">
-      <div>
-        <h1 class="heading">Service Users</h1>
-      </div>
-      <div id="createNewContainer"
-           class$="[[_computeCreateClass(createNew)]]"
-           hidden$="[[!_canCreate]]">
-        <gr-button primary
-                   link
-                   id="createNew"
-                   on-click="_createNewServiceUser">
-          Create New
-        </gr-button>
-      </div>
-    </div>
-    <table id="list"
-           class="genericList">
-      <tr class="headerRow">
-        <th class="name topHeader">Username</th>
-        <th class="fullName topHeader">Full Name</th>
-        <th class="email topHeader">Email</th>
-        <th class="owner topHeader">Owner</th>
-        <th class="createdBy topHeader">Created By</th>
-        <th class="createdAt topHeader">Created At</th>
-        <th class="accountState topHeader">Account State</th>
-      </tr>
-      <tr id="loading"
-          class$="loadingMsg [[_computeLoadingClass(_loading)]]">
-        <td>Loading...</td>
-      </tr>
-      <tbody class$="[[_computeLoadingClass(_loading)]]">
-        <template is="dom-repeat"
-                  items="[[_serviceUsers]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeServiceUserUrl(item._account_id)]]">[[item.username]]</a>
-            </td>
-            <td class="fullName">[[item.name]]</td>
-            <td class="email">[[item.email]]</td>
-            <td class="owner">[[_getOwnerGroup(item)]]</td>
-            <td class="createdBy">[[_getCreator(item)]]</td>
-            <td class="createdAt">[[item.created_at]]</td>
-            <td class="accountState">[[_active(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-`;
diff --git a/gr-serviceuser/gr-serviceuser-ssh-panel.js b/gr-serviceuser/gr-serviceuser-ssh-panel.js
deleted file mode 100644
index 006bdd3..0000000
--- a/gr-serviceuser/gr-serviceuser-ssh-panel.js
+++ /dev/null
@@ -1,114 +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.
- */
-
-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);
-          this._newKey = '';
-        }).finally(() => {
-          this.$.addButton.disabled = false;
-          this.$.newKey.disabled = false;
-        });
-  }
-
-  _computeAddButtonDisabled(newKey) {
-    return !newKey.length;
-  }
-}
-
-customElements.define(GrServiceUserSshPanel.is, GrServiceUserSshPanel);
diff --git a/gr-serviceuser/gr-serviceuser-ssh-panel_html.js b/gr-serviceuser/gr-serviceuser-ssh-panel_html.js
deleted file mode 100644
index a9fb7d9..0000000
--- a/gr-serviceuser/gr-serviceuser-ssh-panel_html.js
+++ /dev/null
@@ -1,129 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      .statusHeader {
-        width: 4em;
-      }
-
-      .keyHeader {
-        width: 7.5em;
-      }
-
-      #viewKeyOverlay {
-        padding: 2em;
-        width: 50em;
-      }
-
-      .publicKey {
-        font-family: var(--monospace-font-family);
-        overflow-x: scroll;
-        overflow-wrap: break-word;
-        width: 30em;
-      }
-
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-
-      #existing {
-        margin-bottom: 1em;
-      }
-
-      #existing .commentColumn {
-        min-width: 27em;
-        width: auto;
-      }
-    </style>
-    <div class="gr-form-styles">
-      <fieldset id="existing">
-        <table>
-          <thead>
-            <tr>
-              <th class="commentColumn">Comment</th>
-              <th class="statusHeader">Status</th>
-              <th class="keyHeader">Public key</th>
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat"
-                      items="[[_keys]]"
-                      as="key">
-              <tr>
-                <td class="commentColumn">[[key.comment]]</td>
-                <td>[[_getStatusLabel(key.valid)]]</td>
-                <td>
-                  <gr-button link
-                             on-click="_showKey"
-                             data-index$="[[index]]"
-                             link>Click to View</gr-button>
-                </td>
-                <td>
-                  <gr-button link
-                             data-index$="[[index]]"
-                             on-click="_handleDeleteKey">Delete</gr-button>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-        <gr-overlay id="viewKeyOverlay"
-                    with-backdrop>
-          <fieldset>
-            <section>
-              <span class="title">Algorithm</span>
-              <span class="value">[[_keyToView.algorithm]]</span>
-            </section>
-            <section>
-              <span class="title">Public key</span>
-              <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-            </section>
-            <section>
-              <span class="title">Comment</span>
-              <span class="value">[[_keyToView.comment]]</span>
-            </section>
-          </fieldset>
-          <gr-button class="closeButton"
-                     on-click="_closeOverlay">Close</gr-button>
-        </gr-overlay>
-      </fieldset>
-      <fieldset>
-        <section>
-          <span class="title">New SSH key</span>
-          <span class="value">
-            <iron-autogrow-textarea id="newKey"
-                                    autocomplete="on"
-                                    bind-value="{{_newKey}}"
-                                    placeholder="New SSH Key">
-            </iron-autogrow-textarea>
-          </span>
-        </section>
-        <gr-button id="addButton"
-                   link
-                   disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-                   on-click="_handleAddKey">
-          Add new SSH key
-        </gr-button>
-      </fieldset>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/gr-serviceuser/gr-serviceuser.js b/gr-serviceuser/gr-serviceuser.js
deleted file mode 100644
index 6c95d9b..0000000
--- a/gr-serviceuser/gr-serviceuser.js
+++ /dev/null
@@ -1,37 +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.
- */
-
-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('/accounts/self/capabilities/').get('')
-      .then(capabilities => {
-        if (capabilities
-            && (capabilities.administrateServer
-                || capabilities['serviceuser-createServiceUser'])) {
-          plugin.screen('create', GrServiceUserCreate.is);
-        }
-        plugin.screen('list', GrServiceUserList.is);
-        plugin.screen('user', GrServiceUserDetail.is);
-        plugin.admin()
-            .addMenuLink(
-                'Service Users',
-                '/x/serviceuser/list');
-      });
-});
diff --git a/web/.eslintrc.js b/web/.eslintrc.js
new file mode 100644
index 0000000..236d266
--- /dev/null
+++ b/web/.eslintrc.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * 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.
+ */
+__plugindir = 'serviceuser/web';
+module.exports = {
+  extends: '../../.eslintrc.js',
+};
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..39c30a2
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,41 @@
+load("//tools/bzl:js.bzl", "gerrit_js_bundle")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+package(default_visibility = [":visibility"])
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/serviceuser/..."],
+)
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "serviceuser-ts",
+    srcs = glob(["*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    supports_workers = True,
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "serviceuser",
+    srcs = [":serviceuser-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+# Run the lint tests with `bazel test plugins/serviceuser/web:lint_test`.
+plugin_eslint()
diff --git a/web/gr-serviceuser-create.ts b/web/gr-serviceuser-create.ts
new file mode 100644
index 0000000..2e6367d
--- /dev/null
+++ b/web/gr-serviceuser-create.ts
@@ -0,0 +1,316 @@
+/**
+ * @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 {customElement, property, query, state} from 'lit/decorators';
+import {css, CSSResult, html, LitElement} from 'lit';
+import {unsafeHTML} from 'lit/directives/unsafe-html';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {
+  AccountId,
+  AccountInfo,
+  GroupInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+
+export interface ConfigInfo {
+  info: string;
+  on_success: string;
+  allow_email: boolean;
+  allow_owner: boolean;
+  allow_http_password: boolean;
+}
+
+export interface ServiceUserInfo extends AccountInfo {
+  created_by?: AccountInfo;
+  created_at?: string;
+  owner?: GroupInfo;
+}
+
+declare interface ServiceUserInput {
+  username?: string;
+  name?: string;
+  ssh_key?: string;
+  email?: string;
+}
+
+@customElement('gr-serviceuser-create')
+export class GrServiceUserCreate extends LitElement {
+  @query('#successDialogModal')
+  successDialogModal!: HTMLDialogElement;
+
+  @query('#serviceUserNameInput')
+  serviceUserNameInput!: HTMLInputElement;
+
+  @query('#serviceUserEmailInput')
+  serviceUserEmailInput!: HTMLInputElement;
+
+  @query('#serviceUserKeyInput')
+  serviceUserKeyInput!: HTMLInputElement;
+
+  @property()
+  plugin!: PluginApi;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @state()
+  infoMessageEnabled = false;
+
+  @state()
+  successMessageEnabled = false;
+
+  @state()
+  emailAllowed = false;
+
+  @state()
+  dataValid = false;
+
+  @state()
+  isAdding = false;
+
+  @property({type: String})
+  infoMessage = '';
+
+  @property({type: String})
+  successMessage = '';
+
+  @property({type: String})
+  username?: String;
+
+  @property({type: String})
+  email?: String;
+
+  @property({type: String})
+  key?: String;
+
+  @property({type: Object})
+  accountId?: AccountId;
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.font as CSSResult,
+      window.Gerrit.styles.form as CSSResult,
+      window.Gerrit.styles.modal as CSSResult,
+      window.Gerrit.styles.subPage as CSSResult,
+      css`
+        main {
+          margin: 2em auto;
+          max-width: 50em;
+        }
+
+        .heading {
+          font-size: x-large;
+          font-weight: 500;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <main class="gr-form-styles read-only">
+        <div class="topHeader">
+          <h1 class="heading">Create Service User</h1>
+        </div>
+        ${this.renderInfoMessage()}
+        <fieldset>
+          <section>
+            <span class="title">Username</span>
+            <span class="value">
+              <input
+                id="serviceUserNameInput"
+                value="${this.username}"
+                type="text"
+                @input="${this.validateData}"
+              />
+            </span>
+          </section>
+          ${this.renderEmailInputSection()}
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">Public SSH key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="serviceUserKeyInput"
+                .bind-value="${this.key}"
+                placeholder="New SSH Key"
+                @bind-value-changed=${this.validateData}
+              >
+              </iron-autogrow-textarea>
+            </span>
+          </section>
+        </fieldset>
+        <gr-button
+          id="createButton"
+          @click=${this.handleCreateServiceUser}
+          ?disabled="${!this.dataValid || this.isAdding}"
+        >
+          Create
+        </gr-button>
+        <dialog id="successDialogModal">
+          <gr-dialog
+            id="successDialog"
+            confirm-label="OK"
+            cancel-label=""
+            @confirm="${this.forwardToDetails}"
+            confirm-on-enter
+          >
+            <div slot="header">Success</div>
+            <div id="successMessage" slot="main">
+              ${this.renderSuccessMessage()}
+            </div>
+          </gr-dialog>
+        </dialog>
+      </main>
+    `;
+  }
+
+  private renderSuccessMessage() {
+    return html`${unsafeHTML(this.successMessage)}`;
+  }
+
+  private renderInfoMessage() {
+    if (this.infoMessageEnabled) {
+      return html`
+        <fieldset id="infoMessage">${unsafeHTML(this.infoMessage)}</fieldset>
+      `;
+    }
+
+    return html``;
+  }
+
+  private renderEmailInputSection() {
+    if (this.emailAllowed) {
+      return html`
+        <section>
+          <span class="title">Email</span>
+          <span class="value">
+            <input
+              id="serviceUserEmailInput"
+              value="${this.email}"
+              type="text"
+              @input="${this.validateData}"
+            />
+          </span>
+        </section>
+      `;
+    }
+
+    return html``;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.pluginRestApi = this.plugin.restApi();
+    this.getConfig();
+  }
+
+  private forwardToDetails() {
+    window.location.href = `${
+      window.location.origin
+    }/x/${this.plugin.getPluginName()}/user/${this.accountId}`;
+  }
+
+  private getConfig() {
+    return this.pluginRestApi
+      .get<ConfigInfo>('/config/server/serviceuser~config/')
+      .then(config => {
+        if (!config) {
+          return;
+        }
+
+        if (config.info && config.info !== '') {
+          this.infoMessageEnabled = true;
+          this.infoMessage = config.info;
+        }
+
+        if (config.on_success && config.on_success !== '') {
+          this.successMessageEnabled = true;
+          this.successMessage = config.on_success;
+        }
+
+        this.emailAllowed = config.allow_email;
+      });
+  }
+
+  private validateData() {
+    this.dataValid =
+      this.validateName(this.serviceUserNameInput.value) &&
+      this.validateEmail(this.serviceUserEmailInput?.value) &&
+      this.validateKey(this.serviceUserKeyInput.value);
+  }
+
+  private validateName(username: String | undefined) {
+    if (username && username.trim().length > 0) {
+      this.username = username;
+      return true;
+    }
+
+    return false;
+  }
+
+  private validateEmail(email: String | undefined) {
+    if (!email || email.trim().length === 0 || email.includes('@')) {
+      this.email = email;
+      return true;
+    }
+
+    return false;
+  }
+
+  private validateKey(key: String | undefined) {
+    if (!key?.trim()) {
+      return false;
+    }
+
+    this.key = key;
+    return true;
+  }
+
+  private handleCreateServiceUser() {
+    this.isAdding = true;
+    const body: ServiceUserInput = {
+      ssh_key: this.key ? this.key.trim() : '',
+      email: this.email ? this.email.trim() : '',
+    };
+    return this.plugin
+      .restApi()
+      .post<ServiceUserInfo>(
+        `/a/config/server/serviceuser~serviceusers/${this.username}`,
+        body
+      )
+      .then(response => {
+        this.accountId = response._account_id;
+        if (this.successMessage) {
+          this.successDialogModal?.showModal();
+        } else {
+          this.forwardToDetails();
+        }
+      })
+      .catch(response => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: response},
+            bubbles: true,
+            composed: true,
+          })
+        );
+        this.isAdding = false;
+      });
+  }
+}
diff --git a/web/gr-serviceuser-detail.ts b/web/gr-serviceuser-detail.ts
new file mode 100644
index 0000000..10decf2
--- /dev/null
+++ b/web/gr-serviceuser-detail.ts
@@ -0,0 +1,610 @@
+/**
+ * @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 {customElement, property, query, state} from 'lit/decorators';
+import {css, CSSResult, html, LitElement} from 'lit';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {GroupInfo} from '@gerritcodereview/typescript-api/rest-api';
+
+import {AccountCapabilityInfo} from './plugin';
+import {ConfigInfo, ServiceUserInfo} from './gr-serviceuser-create';
+import {GrServiceUserSshPanel} from './gr-serviceuser-ssh-panel';
+import {GrServiceUserHttpPassword} from './gr-serviceuser-http-password';
+
+import './gr-serviceuser-ssh-panel';
+import './gr-serviceuser-http-password';
+
+const NOT_FOUND_MESSAGE = 'Not Found';
+
+@customElement('gr-serviceuser-detail')
+export class GrServiceUserDetail extends LitElement {
+  @query('#sshEditor')
+  sshEditor!: GrServiceUserSshPanel;
+
+  @query('#httpPass')
+  httpPass!: GrServiceUserHttpPassword;
+
+  @query('#serviceUserFullNameInput')
+  serviceUserFullNameInput!: HTMLInputElement;
+
+  @query('#serviceUserEmailInput')
+  serviceUserEmailInput!: HTMLInputElement;
+
+  @query('#serviceUserOwnerInput')
+  serviceUserOwnerInput!: HTMLInputElement;
+
+  @property({type: Object})
+  plugin!: PluginApi;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property({type: String})
+  serviceUserId?: String;
+
+  @property({type: Object})
+  serviceUser!: ServiceUserInfo;
+
+  @state()
+  loading = true;
+
+  @state()
+  statusButtonText = 'Activate';
+
+  @state()
+  prefsChanged = false;
+
+  @state()
+  changingPrefs = false;
+
+  @state()
+  isAdmin = false;
+
+  @state()
+  emailAllowed = false;
+
+  @state()
+  ownerAllowed = false;
+
+  @state()
+  httpPasswordAllowed = false;
+
+  @property({type: String})
+  fullName?: String;
+
+  @property({type: String})
+  email?: String;
+
+  @property({type: Array})
+  availableOwners?: Array<GroupInfo>;
+
+  @property({type: String})
+  owner = NOT_FOUND_MESSAGE;
+
+  @property({type: String})
+  ownerChangeWarning?: String;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.pluginRestApi = this.plugin.restApi();
+  }
+
+  override firstUpdated() {
+    this.extractUserId();
+    this.loadServiceUser();
+  }
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.font as CSSResult,
+      window.Gerrit.styles.form as CSSResult,
+      window.Gerrit.styles.subPage as CSSResult,
+      css`
+        main {
+          margin: 2em auto;
+          max-width: 50em;
+        }
+
+        .heading {
+          font-size: x-large;
+          font-weight: 500;
+        }
+
+        h1#Title {
+          margin-bottom: 1em;
+        }
+
+        p#ownerChangeWarning {
+          margin-top: 1em;
+          margin-bottom: 1em;
+        }
+
+        span#gr_serviceuser_activity {
+          border-radius: 1em;
+          width: 10em;
+          padding: 0.3em;
+          font-weight: bold;
+          text-align: center;
+        }
+
+        span.value {
+          width: 50%;
+        }
+
+        input.wide {
+          width: var(--paper-input-container-shared-input-style_-_width);
+        }
+
+        span.Active {
+          background-color: #9fcc6b;
+        }
+
+        span.Inactive {
+          background-color: #f7a1ad;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <main class="gr-form-styles read-only">
+        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
+        <div id="loadedContent" class="${this.computeLoadingClass()}">
+          <h1 id="Title" class="heading">
+            Service User "${this.serviceUser?.name}"
+          </h1>
+          <div id="form">
+            <fieldset>
+              <fieldset>
+                <h2 id="accountState" class="heading-2">Account State</h2>
+                <section>
+                  <span class="title">Current State</span>
+                  <span
+                    id="gr_serviceuser_activity"
+                    class="value ${this.active()}"
+                  >
+                    ${this.active()}
+                  </span>
+                </section>
+                <gr-button
+                  id="statusToggleButton"
+                  @click="${this.toggleStatus}"
+                  ?disabled="${this.loading}"
+                >
+                  ${this.statusButtonText}
+                </gr-button>
+              </fieldset>
+              <fieldset>
+                <h2 id="userDataHeader" class="heading-2">User Data</h2>
+                <section>
+                  <span class="title">Username</span>
+                  <span class="value">${this.serviceUser?.username}</span>
+                </section>
+                ${this.renderFullNameFormSection()}
+                <section>
+                  <span class="title">Email Address</span>
+                  <span class="value"> ${this.renderEmailFormContent()} </span>
+                </section>
+                <section>
+                  <span class="title">Owner Group</span>
+                  <span class="value">
+                    ${this.renderOwnerGroupFormContent()}
+                  </span>
+                </section>
+                <p id="ownerChangeWarning" class="style-scope gr-settings-view">
+                  ${this.ownerChangeWarning}
+                </p>
+                <gr-button
+                  id="savePrefs"
+                  @click="${this.handleSavePreferences}"
+                  ?disabled="${!this.prefsChanged}"
+                >
+                  Save changes
+                </gr-button>
+              </fieldset>
+              <fieldset>
+                <h2 id="creationHeader" class="heading-2">Creation</h2>
+                <section>
+                  <span class="title">Created By</span>
+                  <span class="value">${this.getCreator()}</span>
+                </section>
+                <section>
+                  <span class="title">Created At</span>
+                  <span class="value">${this.serviceUser?.created_at}</span>
+                </section>
+              </fieldset>
+              <fieldset>
+                <h2 id="credentialsHeader" class="heading-2">Credentials</h2>
+                ${this.renderHttpCredentialsForm()}
+                <fieldset>
+                  <h3 id="SSHKeys">SSH keys</h3>
+                  <gr-serviceuser-ssh-panel
+                    id="sshEditor"
+                  ></gr-serviceuser-ssh-panel>
+                </fieldset>
+              </fieldset>
+            </fieldset>
+          </div>
+        </div>
+      </main>
+    `;
+  }
+
+  private renderFullNameFormSection() {
+    return html`
+      <section>
+        <span class="title">Full Name</span>
+        <span class="value">
+          <input
+            id="serviceUserFullNameInput"
+            type="text"
+            class="wide"
+            .value="${this.fullName}"
+            .placeholder="${this.serviceUser?.name}"
+            ?disabled="${this.changingPrefs}"
+            @input="${this.fullNameChanged}"
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailFormContent() {
+    if (this.emailAllowed) {
+      return html`
+        <input
+          id="serviceUserEmailInput"
+          type="text"
+          class="wide"
+          .value="${this.email}"
+          .placeholder="${this.serviceUser?.email}"
+          ?disabled="${this.changingPrefs}"
+          @input="${this.emailChanged}"
+        />
+      `;
+    }
+
+    return html`${this.serviceUser?.email}`;
+  }
+
+  private renderOwnerGroupFormContent() {
+    if (this.ownerAllowed) {
+      return html`
+        <gr-autocomplete
+          id="serviceUserOwnerInput"
+          .text="${this.owner}"
+          .value="${this.owner}"
+          .query="${(input: string) => this.getGroupSuggestions(input)}"
+          ?disabled="${this.changingPrefs}"
+          @value-changed="${this.ownerChanged}"
+          @text-changed="${this.ownerChanged}"
+        >
+          ${this.getCurrentOwnerGroup()}
+        </gr-autocomplete>
+      `;
+    }
+
+    return html`${this.getCurrentOwnerGroup()}`;
+  }
+
+  private renderHttpCredentialsForm() {
+    if (this.httpPasswordAllowed) {
+      return html`
+        <fieldset>
+          <h3 id="HTTPCredentials">HTTP Credentials</h3>
+          <fieldset>
+            <gr-serviceuser-http-password id="httpPass">
+              </gr-http-password>
+          </fieldset>
+        </fieldset>
+      `;
+    }
+
+    return html``;
+  }
+
+  private loadServiceUser() {
+    if (!this.serviceUserId) {
+      return;
+    }
+
+    const promises = [];
+
+    promises.push(this.getPluginConfig());
+    promises.push(this.getServiceUser());
+
+    Promise.all(promises).then(() => {
+      this.sshEditor.loadData(this.pluginRestApi);
+      if (this.httpPasswordAllowed) {
+        this.httpPass.loadData(this.pluginRestApi);
+      }
+
+      this.dispatchEvent(
+        new CustomEvent('title-change', {
+          detail: {title: this.serviceUser?.name},
+          bubbles: true,
+          composed: true,
+        })
+      );
+      this.computeStatusButtonText();
+      this.loading = false;
+      this.fullName = this.serviceUser?.name;
+      this.email = this.serviceUser?.email;
+      this.owner = this.getCurrentOwnerGroup() ?? NOT_FOUND_MESSAGE;
+    });
+  }
+
+  private computeLoadingClass() {
+    return this.loading ? 'loading' : '';
+  }
+
+  private extractUserId() {
+    this.serviceUserId = this.baseURI.split('/').pop();
+  }
+
+  private getPermissions() {
+    return this.pluginRestApi
+      .get<AccountCapabilityInfo>('/accounts/self/capabilities/')
+      .then(capabilities => {
+        if (!capabilities) {
+          this.isAdmin = false;
+        } else {
+          this.isAdmin =
+            capabilities.administrateServer === undefined ? false : true;
+        }
+      });
+  }
+
+  private getPluginConfig() {
+    return Promise.resolve(this.getPermissions()).then(() => {
+      this.pluginRestApi
+        .get<ConfigInfo>('/config/server/serviceuser~config/')
+        .then(config => {
+          if (!config) {
+            return;
+          }
+          this.emailAllowed = config.allow_email || this.isAdmin;
+          this.ownerAllowed = config.allow_owner || this.isAdmin;
+          this.httpPasswordAllowed = config.allow_http_password || this.isAdmin;
+        });
+    });
+  }
+
+  private getServiceUser() {
+    return this.pluginRestApi
+      .get(`/a/config/server/serviceuser~serviceusers/${this.serviceUserId}`)
+      .then(serviceUser => {
+        if (!serviceUser) {
+          this.serviceUser = {};
+          return;
+        }
+        this.serviceUser = serviceUser;
+      });
+  }
+
+  private active() {
+    if (!this.serviceUser) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    return this.serviceUser?.inactive === true ? 'Inactive' : 'Active';
+  }
+
+  private computeStatusButtonText() {
+    if (!this.serviceUser) {
+      return;
+    }
+
+    this.statusButtonText =
+      this.serviceUser?.inactive === true ? 'Activate' : 'Deactivate';
+  }
+
+  private toggleStatus() {
+    if (this.serviceUser?.inactive === true) {
+      this.pluginRestApi
+        .put(
+          `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/active`
+        )
+        .then(() => {
+          this.loadServiceUser();
+        });
+    } else {
+      this.pluginRestApi
+        .delete(
+          `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/active`
+        )
+        .then(() => {
+          this.loadServiceUser();
+        });
+    }
+  }
+
+  private getCreator() {
+    if (!this.serviceUser || !this.serviceUser?.created_by) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    if (this.serviceUser?.created_by.username !== undefined) {
+      return this.serviceUser?.created_by.username;
+    }
+
+    if (this.serviceUser?.created_by._account_id !== -1) {
+      return this.serviceUser?.created_by._account_id;
+    }
+
+    return NOT_FOUND_MESSAGE;
+  }
+
+  private getCurrentOwnerGroup() {
+    return this.serviceUser && this.serviceUser?.owner
+      ? this.serviceUser?.owner.name
+      : NOT_FOUND_MESSAGE;
+  }
+
+  private isEmailValid(email: String) {
+    if (!email) {
+      return false;
+    }
+    return email.includes('@');
+  }
+
+  private getGroupSuggestions(input: String) {
+    return this.pluginRestApi
+      .get<Object>(`/a/groups/?n=10&suggest=${input}`)
+      .then(response => {
+        this.availableOwners = Object.values(response);
+        return Object.keys(response).map(name => {
+          return {name, value: name};
+        });
+      });
+  }
+
+  private isNewOwner() {
+    if (this.owner === NOT_FOUND_MESSAGE) {
+      return false;
+    }
+    return this.owner !== this.getCurrentOwnerGroup();
+  }
+
+  private computeOwnerWarning() {
+    let message = 'If ';
+    message +=
+      this.getCurrentOwnerGroup() !== NOT_FOUND_MESSAGE
+        ? 'the owner group is changed'
+        : 'an owner group is set';
+    message += ' only members of the ';
+    message += this.getCurrentOwnerGroup() !== NOT_FOUND_MESSAGE ? 'new ' : '';
+    message += 'owner group can see and administrate the service user.';
+    message +=
+      this.getCurrentOwnerGroup() !== 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;
+  }
+
+  private fullNameChanged() {
+    this.fullName = this.serviceUserFullNameInput.value;
+    this.computePrefsChanged();
+  }
+
+  private emailChanged() {
+    const newEmail = this.serviceUserEmailInput.value;
+    if (this.isEmailValid(newEmail)) {
+      this.email = this.serviceUserEmailInput.value;
+      this.computePrefsChanged();
+    }
+  }
+
+  private ownerChanged() {
+    this.owner = this.serviceUserOwnerInput.value;
+
+    if (this.isNewOwner()) {
+      this.computeOwnerWarning();
+    }
+
+    this.computePrefsChanged();
+  }
+
+  private computePrefsChanged() {
+    if (this.loading || this.changingPrefs) {
+      return;
+    }
+
+    if (
+      this.owner === this.getCurrentOwnerGroup() &&
+      this.email === this.serviceUser.email &&
+      this.fullName === this.serviceUser.name
+    ) {
+      this.prefsChanged = false;
+      return;
+    }
+
+    this.prefsChanged = true;
+  }
+
+  private applyNewFullName() {
+    return this.pluginRestApi.put(
+      `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/name`,
+      {name: this.fullName}
+    );
+  }
+
+  private applyNewEmail() {
+    if (!this.isEmailValid(this.email ?? '')) {
+      return;
+    }
+    return this.pluginRestApi.put(
+      `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/email`,
+      {email: this.email}
+    );
+  }
+
+  private applyNewOwner() {
+    if (!this.isNewOwner()) {
+      return;
+    }
+    if (this.owner === '') {
+      return this.pluginRestApi.delete(
+        `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/owner`
+      );
+    }
+    return this.pluginRestApi.put(
+      `/config/server/serviceuser~serviceusers/${this.serviceUser?._account_id}/owner`,
+      {group: this.owner}
+    );
+  }
+
+  private handleSavePreferences() {
+    const promises = [];
+    this.changingPrefs = true;
+
+    if (this.fullName !== this.serviceUser.name) {
+      promises.push(this.applyNewFullName());
+    }
+
+    if (this.email !== this.serviceUser.email) {
+      promises.push(this.applyNewEmail());
+    }
+
+    if (this.owner !== this.serviceUser.owner?.name) {
+      promises.push(this.applyNewOwner());
+    }
+
+    Promise.all(promises)
+      .then(() => {
+        this.prefsChanged = false;
+        this.ownerChangeWarning = '';
+        this.loadServiceUser();
+      })
+      .catch(error => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: error},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      })
+      .finally(() => {
+        this.changingPrefs = false;
+      });
+  }
+}
diff --git a/web/gr-serviceuser-http-password.ts b/web/gr-serviceuser-http-password.ts
new file mode 100644
index 0000000..40270f6
--- /dev/null
+++ b/web/gr-serviceuser-http-password.ts
@@ -0,0 +1,144 @@
+/**
+ * @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 {customElement, property, query} from 'lit/decorators';
+import {css, CSSResult, html, LitElement} from 'lit';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+
+@customElement('gr-serviceuser-http-password')
+export class GrServiceUserHttpPassword extends LitElement {
+  @query('#generatedPasswordModal')
+  generatedPasswordModal?: HTMLDialogElement;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property({type: String})
+  serviceUserId?: String;
+
+  @property({type: String})
+  generatedPassword?: String;
+
+  loadData(pluginRestApi: RestPluginApi) {
+    this.pluginRestApi = pluginRestApi;
+    this.serviceUserId = this.baseURI.split('/').pop();
+  }
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.font as CSSResult,
+      window.Gerrit.styles.form as CSSResult,
+      window.Gerrit.styles.modal as CSSResult,
+      css`
+        .password {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #generatedPasswordModal {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        #generatedPasswordDisplay {
+          margin: var(--spacing-l) 0;
+        }
+        #generatedPasswordDisplay .title {
+          width: unset;
+        }
+        #generatedPasswordDisplay .value {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #passwordWarning {
+          font-style: italic;
+          text-align: center;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div class="gr-form-styles">
+        <div>
+          <gr-button id="generateButton" @click=${this.handleGenerateTap}
+            >Generate new password</gr-button
+          >
+          <gr-button id="deleteButton" @click="${this.handleDelete}"
+            >Delete password</gr-button
+          >
+        </div>
+      </div>
+      <dialog
+        tabindex="-1"
+        id="generatedPasswordModal"
+        @closed=${this.generatedPasswordModalClosed}
+      >
+        <div class="gr-form-styles">
+          <section id="generatedPasswordDisplay">
+            <span class="title">New Password:</span>
+            <span class="value">${this.generatedPassword}</span>
+            <gr-copy-clipboard
+              hasTooltip=""
+              buttonTitle="Copy password to clipboard"
+              hideInput=""
+              .text=${this.generatedPassword}
+            >
+            </gr-copy-clipboard>
+          </section>
+          <section id="passwordWarning">
+            This password will not be displayed again.<br />
+            If you lose it, you will need to generate a new one.
+          </section>
+          <gr-button link="" class="closeButton" @click=${this.closeModal}
+            >Close</gr-button
+          >
+        </div>
+      </dialog>`;
+  }
+
+  private handleGenerateTap() {
+    this.generatedPassword = 'Generating...';
+    this.generatedPasswordModal?.showModal();
+    this.pluginRestApi
+      .put<String>(`/a/accounts/${this.serviceUserId}/password.http`, {
+        generate: true,
+      })
+      .then(newPassword => {
+        this.generatedPassword = newPassword;
+      });
+  }
+
+  private closeModal() {
+    this.generatedPasswordModal?.close();
+  }
+
+  private generatedPasswordModalClosed() {
+    this.generatedPassword = '';
+  }
+
+  private handleDelete() {
+    this.pluginRestApi.delete(
+      `/a/accounts/${this.serviceUserId}/password.http`
+    );
+  }
+}
diff --git a/web/gr-serviceuser-list.ts b/web/gr-serviceuser-list.ts
new file mode 100644
index 0000000..b4bece4
--- /dev/null
+++ b/web/gr-serviceuser-list.ts
@@ -0,0 +1,223 @@
+/**
+ * @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 {customElement, property, state} from 'lit/decorators';
+import {css, CSSResult, html, LitElement} from 'lit';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {AccountId} from '@gerritcodereview/typescript-api/rest-api';
+
+import {AccountCapabilityInfo} from './plugin';
+import {ServiceUserInfo} from './gr-serviceuser-create';
+
+const NOT_FOUND_MESSAGE = 'Not Found';
+
+@customElement('gr-serviceuser-list')
+export class GrServiceUserList extends LitElement {
+  @property()
+  plugin!: PluginApi;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @state()
+  loading = true;
+
+  @state()
+  canCreate = false;
+
+  @property({type: Array})
+  serviceUsers = new Array<ServiceUserInfo>();
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.font as CSSResult,
+      window.Gerrit.styles.table as CSSResult,
+      css`
+        .topHeader {
+          padding: 8px;
+        }
+
+        .heading {
+          font-size: x-large;
+          font-weight: 500;
+        }
+
+        #topContainer {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: space-between;
+          margin: 0 1em;
+        }
+
+        #createNewContainer {
+          display: block;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="topContainer">
+        <div>
+          <h1 class="heading">Service Users</h1>
+        </div>
+        ${this.renderCreateButton()}
+      </div>
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Username</th>
+          <th class="fullName topHeader">Full Name</th>
+          <th class="email topHeader">Email</th>
+          <th class="owner topHeader">Owner</th>
+          <th class="createdBy topHeader">Created By</th>
+          <th class="createdAt topHeader">Created At</th>
+          <th class="accountState topHeader">Account State</th>
+        </tr>
+        <tr id="loading" class="loadingMsg ${this.computeLoadingClass()}">
+          <td>Loading...</td>
+        </tr>
+        <tbody class="${this.computeLoadingClass()}">
+          ${this.serviceUsers.map(serviceUser =>
+            this.renderServiceUserList(serviceUser)
+          )}
+        </tbody>
+      </table>
+    `;
+  }
+
+  private renderCreateButton() {
+    if (this.canCreate) {
+      return html`
+        <div id="createNewContainer">
+          <gr-button
+            primary
+            link
+            id="createNew"
+            @click="${this.createNewServiceUser}"
+          >
+            Create New
+          </gr-button>
+        </div>
+      `;
+    }
+    return html``;
+  }
+
+  private renderServiceUserList(serviceUser: ServiceUserInfo) {
+    if (!serviceUser._account_id) {
+      return;
+    }
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href="${this.computeServiceUserUrl(serviceUser._account_id)}"
+            >${serviceUser.name}</a
+          >
+        </td>
+        <td class="fullName">${serviceUser.name}</td>
+        <td class="email">${serviceUser.email}</td>
+        <td class="owner">${this.getOwnerGroup(serviceUser)}</td>
+        <td class="createdBy">${this.getCreator(serviceUser)}</td>
+        <td class="createdAt">${serviceUser.created_at}</td>
+        <td class="accountState">${this.active(serviceUser)}</td>
+      </tr>
+    `;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.pluginRestApi = this.plugin.restApi();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Service Users'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    Promise.all(Array.of(this.getPermissions(), this.getServiceUsers())).then(
+      () => (this.loading = false)
+    );
+  }
+
+  private getPermissions() {
+    return this.pluginRestApi
+      .get<AccountCapabilityInfo>('/accounts/self/capabilities/')
+      .then(capabilities => {
+        this.canCreate =
+          capabilities &&
+          (capabilities.administrateServer ||
+            capabilities['serviceuser-createServiceUser']);
+      });
+  }
+
+  private getServiceUsers() {
+    return this.pluginRestApi
+      .get<Object>('/a/config/server/serviceuser~serviceusers/')
+      .then(serviceUsers => {
+        new Map<String, ServiceUserInfo>(Object.entries(serviceUsers)).forEach(
+          v => this.serviceUsers.push(v)
+        );
+      });
+  }
+
+  private computeLoadingClass() {
+    return this.loading ? 'loading' : '';
+  }
+
+  private active(item: ServiceUserInfo) {
+    if (!item) {
+      return NOT_FOUND_MESSAGE;
+    }
+
+    return item.inactive === true ? 'Inactive' : 'Active';
+  }
+
+  private getCreator(item: ServiceUserInfo) {
+    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;
+  }
+
+  private getOwnerGroup(item: ServiceUserInfo) {
+    return item && item.owner ? item.owner.name : NOT_FOUND_MESSAGE;
+  }
+
+  private computeServiceUserUrl(id: AccountId) {
+    return `${
+      window.location.origin
+    }/x/${this.plugin.getPluginName()}/user/${id}`;
+  }
+
+  private createNewServiceUser() {
+    window.location.href = `${
+      window.location.origin
+    }/x/${this.plugin.getPluginName()}/create`;
+  }
+}
diff --git a/web/gr-serviceuser-ssh-panel.ts b/web/gr-serviceuser-ssh-panel.ts
new file mode 100644
index 0000000..74b6a97
--- /dev/null
+++ b/web/gr-serviceuser-ssh-panel.ts
@@ -0,0 +1,304 @@
+/**
+ * @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 {customElement, property, query, state} from 'lit/decorators';
+import {css, CSSResult, html, LitElement, PropertyValues} from 'lit';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+
+export interface BindValueChangeEventDetail {
+  value: string | undefined;
+}
+export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
+
+// TODO: Remove when it is released with typescript API
+export interface SshKeyInfo {
+  seq: number;
+  ssh_public_key: string;
+  encoded_key: string;
+  algorithm: string;
+  comment?: string;
+  valid: boolean;
+}
+
+@customElement('gr-serviceuser-ssh-panel')
+export class GrServiceUserSshPanel extends LitElement {
+  @query('#addButton') addButton!: HTMLButtonElement;
+
+  @query('#newKey') newKeyEditor!: HTMLTextAreaElement;
+
+  @query('#viewKeyModal') viewKeyModal!: HTMLDialogElement;
+
+  @property({type: Boolean})
+  hasUnsavedChanges = false;
+
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property({type: String})
+  serviceUserId?: String;
+
+  @property({type: Array})
+  keys: SshKeyInfo[] = [];
+
+  @property({type: Object})
+  keyToView?: SshKeyInfo;
+
+  @property({type: String})
+  newKey = '';
+
+  @property({type: Array})
+  keysToRemove: SshKeyInfo[] = [];
+
+  @state() prevHasUnsavedChanges = false;
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.form as CSSResult,
+      window.Gerrit.styles.modal as CSSResult,
+      css`
+        .statusHeader {
+          width: 4em;
+        }
+        .keyHeader {
+          width: 7.5em;
+        }
+        #viewKeyModal {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        .publicKey {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          overflow-x: scroll;
+          overflow-wrap: break-word;
+          width: 30em;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        #existing {
+          margin-bottom: var(--spacing-l);
+        }
+        #existing .commentColumn {
+          min-width: 27em;
+          width: auto;
+        }
+        iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+        }
+      `,
+    ];
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasUnsavedChanges')) {
+      if (this.prevHasUnsavedChanges === this.hasUnsavedChanges) return;
+      this.prevHasUnsavedChanges = this.hasUnsavedChanges;
+      this.dispatchEvent(
+        new CustomEvent('has-unsaved-changes-changed', {
+          detail: {value: this.hasUnsavedChanges},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="commentColumn">Comment</th>
+                <th class="statusHeader">Status</th>
+                <th class="keyHeader">Public key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <dialog id="viewKeyModal" tabindex="-1">
+            <fieldset>
+              <section>
+                <span class="title">Algorithm</span>
+                <span class="value">${this.keyToView?.algorithm}</span>
+              </section>
+              <section>
+                <span class="title">Public key</span>
+                <span class="value publicKey"
+                  >${this.keyToView?.encoded_key}</span
+                >
+              </section>
+              <section>
+                <span class="title">Comment</span>
+                <span class="value">${this.keyToView?.comment}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => this.viewKeyModal.close()}
+              >Close</gr-button
+            >
+          </dialog>
+          <gr-button
+            @click=${() => this.save()}
+            ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New SSH key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                placeholder="New SSH Key"
+                .bindValue=${this.newKey}
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  this.newKey = e.detail.value ?? '';
+                }}
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            link=""
+            ?disabled=${!this.newKey.length}
+            @click=${() => this.handleAddKey()}
+            >Add new SSH key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: SshKeyInfo, index: number) {
+    return html` <tr>
+      <td class="commentColumn">${key.comment}</td>
+      <td>${key.valid ? 'Valid' : 'Invalid'}</td>
+      <td>
+        <gr-button
+          link=""
+          @click=${(e: Event) => this.showKey(e)}
+          data-index=${index}
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip=""
+          .buttonTitle=${'Copy SSH public key to clipboard'}
+          hideInput=""
+          .text=${key.ssh_public_key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button
+          link=""
+          data-index=${index}
+          @click=${(e: Event) => this.handleDeleteKey(e)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  loadData(pluginRestApi: RestPluginApi) {
+    this.pluginRestApi = pluginRestApi;
+    this.serviceUserId = this.baseURI.split('/').pop();
+    return this.pluginRestApi
+      .get<Array<SshKeyInfo>>(
+        `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys`
+      )
+      .then(keys => {
+        if (!keys) {
+          this.keys = [];
+          return;
+        }
+        this.keys = keys;
+      });
+  }
+
+  private save() {
+    const promises = this.keysToRemove.map(key =>
+      this.pluginRestApi.delete(
+        `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys/${key.seq}`
+      )
+    );
+    return Promise.all(promises).then(() => {
+      this.keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  private showKey(e: Event) {
+    const el = e.target as HTMLBaseElement;
+    const index = Number(el.getAttribute('data-index'));
+    this.keyToView = this.keys[index];
+    this.viewKeyModal.showModal();
+  }
+
+  private handleDeleteKey(e: Event) {
+    const el = e.target as HTMLBaseElement;
+    const index = Number(el.getAttribute('data-index')!);
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
+    this.hasUnsavedChanges = true;
+  }
+
+  private handleAddKey() {
+    this.addButton.disabled = true;
+    this.newKeyEditor.disabled = true;
+    return this.pluginRestApi
+      .post<SshKeyInfo>(
+        `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys`,
+        this.newKey.trim(),
+        undefined,
+        'plain/text'
+      )
+      .then(key => {
+        this.newKeyEditor.disabled = false;
+        this.newKey = '';
+        this.keys.push(key);
+        this.requestUpdate();
+      })
+      .catch(error => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: error},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      })
+      .finally(() => {
+        this.addButton.disabled = false;
+        this.newKeyEditor.disabled = false;
+      });
+  }
+}
diff --git a/web/plugin.ts b/web/plugin.ts
new file mode 100644
index 0000000..1f777f0
--- /dev/null
+++ b/web/plugin.ts
@@ -0,0 +1,45 @@
+/**
+ * @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 '@gerritcodereview/typescript-api/gerrit';
+
+import './gr-serviceuser-create';
+import './gr-serviceuser-detail';
+import './gr-serviceuser-list';
+
+export interface AccountCapabilityInfo {
+  administrateServer: boolean;
+  'serviceuser-createServiceUser': boolean;
+}
+
+window.Gerrit.install(plugin => {
+  plugin
+    .restApi()
+    .get<AccountCapabilityInfo>('/accounts/self/capabilities/')
+    .then(capabilities => {
+      if (
+        capabilities &&
+        (capabilities['administrateServer'] ||
+          capabilities['serviceuser-createServiceUser'])
+      ) {
+        plugin.screen('create', 'gr-serviceuser-create');
+      }
+      plugin.screen('list', 'gr-serviceuser-list');
+      plugin.screen('user', 'gr-serviceuser-detail');
+      plugin.admin().addMenuLink('Service Users', '/x/serviceuser/list');
+    });
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..8c9697d
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/serviceuser", /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ],
+}