Merge changes I0df46558,Ib93db1e0,I6e7e9fee,Ibcbfac31,I63c2a798 into stable-3.0

* changes:
  Add PolyGerrit UI for creating a service user
  Modify the detail-screen to allow editing the service user
  Add PolyGerrit UI for showing service user details
  Add PolyGerrit UI to list service users
  Add eslint config
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..3a9c4b9
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,79 @@
+{
+    "extends": ["eslint:recommended", "google"],
+    "parserOptions": {
+      "ecmaVersion": 8
+    },
+    "env": {
+      "browser": true,
+      "es6": true
+    },
+    "globals": {
+      "__dirname": false,
+      "app": false,
+      "page": false,
+      "Polymer": false,
+      "process": false,
+      "require": false,
+      "Gerrit": false,
+      "Promise": false,
+      "assert": false,
+      "test": false,
+      "flushAsynchronousOperations": false
+    },
+    "rules": {
+      "arrow-parens": ["error", "as-needed"],
+      "block-spacing": ["error", "always"],
+      "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
+      "camelcase": "off",
+      "comma-dangle": ["error", "always-multiline"],
+      "eol-last": "off",
+      "indent": "off",
+      "indent-legacy": ["error", 2, {
+        "MemberExpression": 2,
+        "FunctionDeclaration": {"body": 1, "parameters": 2},
+        "FunctionExpression": {"body": 1, "parameters": 2},
+        "CallExpression": {"arguments": 2},
+        "ArrayExpression": 1,
+        "ObjectExpression": 1,
+        "SwitchCase": 1
+      }],
+      "keyword-spacing": ["error", { "after": true, "before": true }],
+      "max-len": [
+        "error",
+        80,
+        2,
+        {"ignoreComments": true}
+      ],
+      "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
+      "no-console": "off",
+      "no-restricted-syntax": [
+        "error",
+        {
+          "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
+          "message": "Remove test.only."
+        },
+        {
+          "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
+          "message": "Remove suite.only."
+        }
+      ],
+      "no-undef": "off",
+      "no-useless-escape": "off",
+      "no-var": "error",
+      "object-shorthand": ["error", "always"],
+      "prefer-arrow-callback": "error",
+      "prefer-const": "error",
+      "prefer-spread": "error",
+      "quote-props": ["error", "consistent-as-needed"],
+      "require-jsdoc": "off",
+      "semi": [2, "always"],
+      "template-curly-spacing": "error",
+      "valid-jsdoc": "off"
+    },
+    "plugins": [
+      "html"
+    ],
+    "settings": {
+      "html/report-bad-indent": "error"
+    }
+  }
diff --git a/BUILD b/BUILD
index 06e79d5..36d967f 100644
--- a/BUILD
+++ b/BUILD
@@ -2,11 +2,12 @@
 
 gerrit_plugin(
     name = "serviceuser",
-    srcs = glob(["src/main/java/**/*.java"]),
+    srcs = glob(["src/main/java/com/googlesource/gerrit/plugins/serviceuser/**/*.java"]),
     manifest_entries = [
         "Gerrit-PluginName: serviceuser",
         "Gerrit-Module: com.googlesource.gerrit.plugins.serviceuser.Module",
+        "Gerrit-HttpModule: com.googlesource.gerrit.plugins.serviceuser.HttpModule",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.serviceuser.SshModule",
     ],
-    resources = glob(["src/main/**/*"]),
+    resources = glob(["src/main/resources/**/*"]),
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
index e955f43..85da7b0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetConfig.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.serviceuser;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -61,6 +62,8 @@
   public ConfigInfo apply(ConfigResource rsrc) throws PermissionBackendException {
     PluginConfig cfg = cfgFactory.getFromGerritConfig(pluginName);
     ConfigInfo info = new ConfigInfo();
+    info.info = Strings.emptyToNull(cfg.getString("infoMessage"));
+    info.onSuccess = Strings.emptyToNull(cfg.getString("onSuccessMessage"));
     info.allowEmail = toBoolean(cfg.getBoolean("allowEmail", false));
     info.allowHttpPassword = toBoolean(cfg.getBoolean("allowHttpPassword", false));
     info.allowOwner = toBoolean(cfg.getBoolean("allowOwner", false));
@@ -92,6 +95,8 @@
   }
 
   public class ConfigInfo {
+    public String info;
+    public String onSuccess;
     public Boolean allowEmail;
     public Boolean allowHttpPassword;
     public Boolean allowOwner;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java
new file mode 100644
index 0000000..a99987b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/HttpModule.java
@@ -0,0 +1,28 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.inject.AbstractModule;
+
+public class HttpModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("gr-serviceuser.html"));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
index ca6f8f0..56d14e1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
@@ -71,5 +70,6 @@
             delete(SERVICE_USER_KIND, "owner").to(PutOwner.class);
           }
         });
+    install(new HttpModule());
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutConfig.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutConfig.java
index 8b45918..09a559e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutConfig.java
@@ -40,6 +40,8 @@
 @Singleton
 class PutConfig implements RestModifyView<ConfigResource, Input> {
   public static class Input {
+    public String info;
+    public String onSuccess;
     public Boolean allowEmail;
     public Boolean allowHttpPassword;
     public Boolean allowOwner;
@@ -71,6 +73,12 @@
       throws IOException, ConfigInvalidException, UnprocessableEntityException {
     FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
     cfg.load();
+    if (input.info != null) {
+      cfg.setString("plugin", pluginName, "infoMessage", Strings.emptyToNull(input.info));
+    }
+    if (input.onSuccess != null) {
+      cfg.setString("plugin", pluginName, "onSuccessMessage", Strings.emptyToNull(input.onSuccess));
+    }
     if (input.allowEmail != null) {
       setBoolean(cfg, "allowEmail", input.allowEmail);
     }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 0793bb6..f52421e 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -22,6 +22,16 @@
 	should be automatically added. Multiple groups can be specified by
 	having multiple `plugin.@PLUGIN@.group` entries.
 
+<a id="infoMessage">
+`plugin.@PLUGIN@.infoMessage`
+:	HTML formatted message that should be displayed on the service user
+	creation screen.
+
+<a id="onSuccessMessage">
+`plugin.@PLUGIN@.onSuccessMessage`
+:	Message that should be displayed after a service user was
+	successfully created.
+
 <a id="allowEmail">
 `plugin.@PLUGIN@.allowEmail`
 :	Whether it is allowed for service user owners to set email
diff --git a/src/main/resources/Documentation/rest-api-config.md b/src/main/resources/Documentation/rest-api-config.md
index 8497fcd..3999e60 100644
--- a/src/main/resources/Documentation/rest-api-config.md
+++ b/src/main/resources/Documentation/rest-api-config.md
@@ -704,6 +704,10 @@
 The `ConfigInfo` entity contains the configuration of the @PLUGIN@
 plugin.
 
+* _info_: HTML formatted message that should be displayed on the
+  service user creation screen.
+* _on\_success_: Message that should be displayed after a service user
+  was successfully created.
 * _allow\_email_: Whether it is allowed to provide an email address for
   a service user (not set if `false`).
 * _allow\_http\_password_: Whether it is allowed to generate an HTTP
@@ -727,6 +731,10 @@
 The `ConfigInput` entity contains updates for the configuration of the
 @PLUGIN@ plugin.
 
+* _info_: HTML formatted message that should be displayed on the
+  service user creation screen.
+* _on\_success_: Message that should be displayed after a service user
+  was successfully created.
 * _allow\_email_: Whether it is allowed to provide an email address for
   a service user (not set if `false`).
 * _allow\_http\_password_: Whether it is allowed to generate an HTTP
diff --git a/src/main/resources/static/gr-serviceuser-create.html b/src/main/resources/static/gr-serviceuser-create.html
new file mode 100644
index 0000000..b6b71fe
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-create.html
@@ -0,0 +1,78 @@
+<!--
+@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.
+-->
+
+<dom-module id="gr-serviceuser-create">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style>
+      main {
+        margin: 2em auto;
+        max-width: 50em;
+      }
+    </style>
+    <main class="gr-form-styles read-only">
+      <div class="topHeader">
+        <h2>Create Service User</h2>
+      </div>
+      <fieldset id="infoMessage"
+           hidden$="[[!_infoMessageEnabled]]">
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title">Username</span>
+          <span class="value">
+            <input id="serviceUserNameInput"
+                   bind-value="{{_newUsername}}"
+                   is="iron-input"
+                   type="text"
+                   on-keyup="_validateData">
+          </span>
+        </section>
+        <section hidden$="[[!_emailEnabled]]">
+          <span class="title">Email</span>
+          <span class="value">
+            <input id="serviceUserEmailInput"
+                   bind-value="{{_newEmail}}"
+                   is="iron-input"
+                   type="text"
+                   on-keyup="_validateData">
+          </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-tap="_handleCreateServiceUser"
+                 disabled="[[!_enableButton]]">
+        Create
+      </gr-button>
+    </main>
+  </template>
+  <script src="gr-serviceuser-create.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-serviceuser-create.js b/src/main/resources/static/gr-serviceuser-create.js
new file mode 100644
index 0000000..bc04de5
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-create.js
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-serviceuser-create',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      _infoMessageEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _infoMessage: String,
+      _successMessageEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _successMessage: String,
+      _newUsername: String,
+      _emailEnabled: {
+        type: Boolean,
+        value: false,
+      },
+      _newEmail: String,
+      _newKey: String,
+      _dataValid: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdding: {
+        type: Boolean,
+        value: false,
+      },
+      _enableButton: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    attached() {
+      this._getConfig();
+    },
+
+    _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._emailEnabled = config.allow_email;
+          });
+    },
+
+    _validateData() {
+      this._dataValid = this._validateName(this._newUsername)
+        && this._validateEmail(this._newEmail)
+        && this._validateKey(this._newKey);
+      this._computeButtonEnabled();
+    },
+
+    _validateName(username) {
+      if (username && username.trim().length > 0) {
+        return true;
+      }
+
+      return false;
+    },
+
+    _validateEmail(email) {
+      if (!email || email.trim().length == 0 || email.includes('@')) {
+        return true;
+      }
+
+      return false;
+    },
+
+    _validateKey(key) {
+      if (!key || !key.trim()) {
+        return false;
+      }
+
+      return true;
+    },
+
+    _computeButtonEnabled() {
+      this._enableButton = this._dataValid && !this._isAdding;
+    },
+
+    _handleCreateServiceUser() {
+      this._isAdding = true;
+      this._computeButtonEnabled();
+      const body = {
+        ssh_key: this._newKey.trim(),
+        email: this._newEmail ? this._newEmail.trim() : null,
+      };
+      return this.plugin.restApi('/config/server/serviceuser~serviceusers/')
+          .post(this._newUsername, body)
+          .then(response => {
+            if (this._successMessage) {
+              this.fire('show-alert', {message: this._successMessage});
+            }
+            page.show(
+                this.plugin.screenUrl()
+              + '/user/'
+              + response._account_id);
+          }).catch(response => {
+            this.fire('show-error', {message: response});
+            this._isAdding = false;
+            this._computeButtonEnabled();
+          });
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-serviceuser-detail.html b/src/main/resources/static/gr-serviceuser-detail.html
new file mode 100644
index 0000000..c65ed43
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-detail.html
@@ -0,0 +1,151 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="./gr-serviceuser-ssh-panel.html">
+<link rel="import" href="./gr-serviceuser-http-password.html">
+
+<dom-module id="gr-serviceuser-detail">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style>
+      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.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">Service User "[[_serviceUser.name]]"</h1>
+          <div id="form">
+            <fieldset>
+              <fieldset>
+                <h2 id="accountState">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-tap="_toggleStatus" disabled="[[_loading]]">
+                  [[_statusButtonText]]</gr-button>
+              </fieldset>
+              <fieldset>
+                <h2 id="userDataHeader">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">
+                    <input id="serviceUserFullNameInput" bind-value="{{_newFullName}}" is="iron-input" type="text"
+                      disabled="[[_changingPrefs]]" placeholder$="[[_serviceUser.name]]"
+                      on-keyup="_computePrefsChanged">
+                  </span>
+                </section>
+                <section>
+                  <span class="title">Email Address</span>
+                  <input id="serviceUserEmailInput" bind-value="{{_newEmail}}" is="iron-input" type="text"
+                    disabled="[[_changingPrefs]]" placeholder="[[_serviceUser.email]]" on-keyup="_computePrefsChanged"
+                    hidden$="[[!_allowEmail]]">
+                  <span class="value" hidden$="[[_allowEmail]]">[[_serviceUser.email]]</span>
+                </section>
+                <section>
+                  <span class="title">Owner Group</span>
+                  <gr-autocomplete id="serviceUserOwnerInput" text="{{_getOwnerGroup(_serviceUser)}}"
+                    value="{{_newOwner}}" query="[[_query]]" disabled="[[_changingPrefs]]"
+                    on-commit="_computePrefsChanged" on-keyup="_computePrefsChanged" hidden$="[[!_allowOwner]]">
+                    [[_getOwnerGroup(_serviceUser)]]
+                  </gr-autocomplete>
+                  <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-tap="_handleSavePreferences" disabled="[[!_prefsChanged]]">
+                  Save changes
+                </gr-button>
+              </fieldset>
+              <fieldset>
+                <h2 id="creationHeader">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">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>
+  </template>
+  <script src="gr-serviceuser-detail.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-serviceuser-detail.js b/src/main/resources/static/gr-serviceuser-detail.js
new file mode 100644
index 0000000..ed9a77a
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-detail.js
@@ -0,0 +1,343 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function() {
+  'use strict';
+
+  const NOT_FOUND_MESSAGE = 'Not Found';
+
+  Polymer({
+    is: 'gr-serviceuser-detail',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      _restApi: Object,
+      _serviceUserId: String,
+      _serviceUser: Object,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _statusButtonText: {
+        type: String,
+        value: 'Activate',
+      },
+      _prefsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _changingPrefs: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _allowEmail: {
+        type: Boolean,
+        value: false,
+      },
+      _allowOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _allowHttpPassword: {
+        type: Boolean,
+        value: false,
+      },
+      _newFullName: String,
+      _newEmail: String,
+      _availableOwners: Array,
+      _newOwner: String,
+      _ownerChangeWarning: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this._extractUserId();
+      this._loadServiceUser();
+    },
+
+    _loadServiceUser() {
+      if (!this._serviceUserId) { return; }
+
+      const promises = [];
+
+      promises.push(this._getPluginConfig());
+      promises.push(this._getServiceUser());
+
+      Promise.all(promises).then(() => {
+        this.$.sshEditor.loadData(this._restApi, this._serviceUser);
+        this.$.httpPass.loadData(this._restApi, this._serviceUser);
+
+        this.fire('title-change', {title: this._serviceUser.name});
+        this._computeStatusButtonText();
+        this._loading = false;
+      });
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _extractUserId() {
+      this._serviceUserId = this.baseURI.split('/').pop();
+    },
+
+    _getPermissions() {
+      return this.plugin.restApi('/accounts/self/capabilities/').get('')
+          .then(capabilities => {
+            this._isAdmin = capabilities && capabilities.administrateServer;
+          });
+    },
+
+    _getPluginConfig() {
+      return Promise.resolve(this._getPermissions()).then(() => {
+        this.plugin.restApi('/config/server/serviceuser~config/').get('')
+            .then(config => {
+              if (!config) {
+                return;
+              }
+              this._allowEmail = config.allow_email || this._isAdmin;
+              this._allowOwner = config.allow_owner || this._isAdmin;
+              this._allowHttpPassword = config.allow_http_password
+                || this._isAdmin;
+            });
+      });
+    },
+
+    _getServiceUser() {
+      this._restApi = this.plugin.restApi(
+          '/config/server/serviceuser~serviceusers/');
+      return this._restApi.get(this._serviceUserId)
+          .then(serviceUser => {
+            if (!serviceUser) {
+              this._serviceUser = {};
+              return;
+            }
+            this._serviceUser = serviceUser;
+          });
+    },
+
+    _active(serviceUser) {
+      if (!serviceUser) {
+        return NOT_FOUND_MESSAGE;
+      }
+
+      return serviceUser.inactive === true ? 'Inactive' : 'Active';
+    },
+
+    _computeStatusButtonText() {
+      if (!this._serviceUser) {
+        return;
+      }
+
+      this._statusButtonText = this._serviceUser.inactive === true
+        ? 'Activate'
+        : 'Deactivate';
+    },
+
+    _toggleStatus() {
+      if (this._serviceUser.inactive === true) {
+        this._restApi.put(`${this._serviceUser._account_id}/active`)
+            .then(() => {
+              this._loadServiceUser();
+            });
+      } else {
+        this._restApi.delete(`${this._serviceUser._account_id}/active`)
+            .then(() => {
+              this._loadServiceUser();
+            });
+      }
+    },
+
+    _getCreator(serviceUser) {
+      if (!serviceUser || !serviceUser.created_by) {
+        return NOT_FOUND_MESSAGE;
+      }
+
+      if (serviceUser.created_by.username != undefined) {
+        return serviceUser.created_by.username;
+      }
+
+      if (serviceUser.created_by._account_id != -1) {
+        return serviceUser.created_by._account_id;
+      }
+
+      return NOT_FOUND_MESSAGE;
+    },
+
+    _getOwnerGroup(serviceUser) {
+      return serviceUser && serviceUser.owner
+        ? serviceUser.owner.name
+        : NOT_FOUND_MESSAGE;
+    },
+
+    _isEmailValid(email) {
+      if (!email) {
+        return false;
+      }
+      return email.includes('@');
+    },
+
+    _getGroupSuggestions(input) {
+      let query;
+      if (!input || input === this._getOwnerGroup(this._serviceUser)) {
+        query = '';
+      } else {
+        query = `?suggest=${input}`;
+      }
+
+      return this.plugin.restApi('/a/groups/').get(query)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: decodeURIComponent(response[key].id),
+              });
+            }
+            this._availableOwners = groups;
+            return groups;
+          });
+    },
+
+    _isOwnerValid(owner) {
+      if (!owner) {
+        return false;
+      }
+
+      return this._getOwnerName(owner);
+    },
+
+    _isNewOwner() {
+      return this._getOwnerName(this._newOwner)
+         === this._getOwnerGroup(this._serviceUser);
+    },
+
+    _getOwnerName(id) {
+      return this._availableOwners.find(o => { return o.value === id; }).name;
+    },
+
+    _computeOwnerWarning() {
+      let message = 'If ';
+      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+        ? 'the owner group is changed' : 'an owner group is set';
+      message += ' only members of the ';
+      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+        ? 'new ' : '';
+      message += 'owner group can see and administrate the service user.';
+      message += this._getOwnerGroup(this._serviceUser) != NOT_FOUND_MESSAGE
+        ? '' : ' The creator of the service user can no'
+          + ' longer see and administrate the service user if she/he'
+          + ' is not member of the owner group.';
+      this._ownerChangeWarning = message;
+    },
+
+    _computePrefsChanged() {
+      if (this.loading || this._changingPrefs) {
+        return;
+      }
+
+      if (!this._newOwner && !this._newEmail && !this._newFullName) {
+        this._prefsChanged = false;
+        return;
+      }
+
+      if (this._newEmail && !this._isEmailValid(this._newEmail)) {
+        this._prefsChanged = false;
+        return;
+      }
+
+      if (this._newOwner
+          && (this._isNewOwner() || !this._isOwnerValid(this._newOwner))) {
+        this._prefsChanged = false;
+        return;
+      }
+
+      if (this._newOwner) {
+        this._computeOwnerWarning();
+      }
+
+      this._prefsChanged = true;
+    },
+
+    _applyNewFullName() {
+      return this._restApi
+          .put(`${this._serviceUser._account_id}/name`,
+               {name: this._newFullName})
+          .then(() => {
+            this.$.serviceUserFullNameInput.value = '';
+          });
+    },
+
+    _applyNewEmail(email) {
+      if (!this._isEmailValid(email)) {
+        return;
+      }
+      return this._restApi
+          .put(`${this._serviceUser._account_id}/email`, {email})
+          .then(() => {
+            this.$.serviceUserEmailInput.value = '';
+          });
+    },
+
+    _applyNewOwner(owner) {
+      if (this._isNewOwner() || !this._isOwnerValid(this._newOwner)) {
+        return;
+      }
+      return this._restApi
+          .put(`${this._serviceUser._account_id}/owner`, {group: owner})
+          .then(() => {
+            this.$.serviceUserOwnerInput.text = this._getOwnerGroup(
+                _serviceUser);
+          });
+    },
+
+    _handleSavePreferences() {
+      const promises = [];
+      this._changingPrefs = true;
+
+      if (this._newFullName) {
+        promises.push(this._applyNewFullName());
+      }
+
+      if (this._newEmail) {
+        promises.push(this._applyNewEmail(this._newEmail));
+      }
+
+      if (this._newOwner) {
+        promises.push(this._applyNewOwner(this._newOwner));
+      }
+
+      Promise.all(promises).then(() => {
+        this._changingPrefs = false;
+        this._prefsChanged = false;
+        this._ownerChangeWarning = '';
+        this._loadServiceUser();
+      });
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-serviceuser-http-password.html b/src/main/resources/static/gr-serviceuser-http-password.html
new file mode 100644
index 0000000..f2f5b3e
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-http-password.html
@@ -0,0 +1,81 @@
+<!--
+@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.
+-->
+
+<dom-module id="gr-serviceuser-http-password">
+  <template>
+    <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-tap="_handleGenerateTap">Generate new password</gr-button>
+        <gr-button id="deleteButton"
+                   on-tap="_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-tap="_closeOverlay">Close</gr-button>
+      </div>
+    </gr-overlay>
+  </template>
+  <script src="gr-serviceuser-http-password.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-serviceuser-http-password.js b/src/main/resources/static/gr-serviceuser-http-password.js
new file mode 100644
index 0000000..87e1543
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-http-password.js
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-serviceuser-http-password',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      _restApi: Object,
+      _serviceUser: Object,
+      _generatedPassword: String,
+      _passwordUrl: String,
+    },
+
+    loadData(restApi, serviceUser) {
+      this._restApi = restApi;
+      this._serviceUser = serviceUser;
+    },
+
+    _handleGenerateTap() {
+      this._generatedPassword = 'Generating...';
+      this.$.generatedPasswordOverlay.open();
+      this._restApi
+          .put(`${this._serviceUser._account_id}/password.http`,
+               {generate: true})
+          .then(newPassword => {
+            this._generatedPassword = newPassword;
+          });
+    },
+
+    _closeOverlay() {
+      this.$.generatedPasswordOverlay.close();
+    },
+
+    _generatedPasswordOverlayClosed() {
+      this._generatedPassword = '';
+    },
+
+    _handleDelete() {
+      this._restApi.delete(`${this._serviceUser._account_id}/password.http`);
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-serviceuser-list.html b/src/main/resources/static/gr-serviceuser-list.html
new file mode 100644
index 0000000..052e9f4
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-list.html
@@ -0,0 +1,84 @@
+<!--
+@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.
+-->
+
+<dom-module id="gr-serviceuser-list">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <style>
+      .topHeader {
+        padding: 8px;
+      }
+
+      #topContainer {
+        align-items: center;
+        display: flex;
+        height: 3rem;
+        justify-content: space-between;
+        margin: 0 1em;
+      }
+    </style>
+    <div class="topHeader">
+      <h2>Service Users</h2>
+    </div>
+    <div id="topContainer">
+      <div></div>
+      <div id="createNewContainer"
+           class$="[[_computeCreateClass(createNew)]]">
+        <gr-button primary
+                   link
+                   id="createNew"
+                   on-tap="_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>
+  </template>
+  <script src="gr-serviceuser-list.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-serviceuser-list.js b/src/main/resources/static/gr-serviceuser-list.js
new file mode 100644
index 0000000..8f35dd4
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-list.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function() {
+  'use strict';
+
+  const NOT_FOUND_MESSAGE = 'Not Found';
+
+  Polymer({
+    is: 'gr-serviceuser-list',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      _serviceUsers: Array,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this.fire('title-change', {title: 'Service Users'});
+      this._getServiceUsers();
+    },
+
+    _getServiceUsers() {
+      return this.plugin.restApi('/config/server/serviceuser~serviceusers/')
+          .get('')
+          .then(serviceUsers => {
+            if (!serviceUsers) {
+              this._serviceUsers = [];
+              return;
+            }
+            this._serviceUsers = Object.keys(serviceUsers)
+            .map(key => {
+              const serviceUser = serviceUsers[key];
+              serviceUser.username = key;
+              return serviceUser;
+            });
+            this._loading = false;
+          });
+    },
+
+    _active(item) {
+      if (!item) {
+        return NOT_FOUND_MESSAGE;
+      }
+
+      return item.inactive === true ? 'Inactive' : 'Active';
+    },
+
+    _getCreator(item) {
+      if (!item || !item.created_by) {
+        return NOT_FOUND_MESSAGE;
+      }
+
+      if (item.created_by.username != undefined) {
+        return item.created_by.username;
+      }
+
+      if (item.created_by._account_id != -1) {
+        return item.created_by._account_id;
+      }
+
+      return NOT_FOUND_MESSAGE;
+    },
+
+    _getOwnerGroup(item) {
+      return item && item.owner ? item.owner.name : NOT_FOUND_MESSAGE;
+    },
+
+    _computeServiceUserUrl(id) {
+      return `${this.plugin.screenUrl()}/user/${id}`;
+    },
+
+    _createNewServiceUser() {
+      page.show(this.plugin.screenUrl() + '/create');
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-serviceuser-ssh-panel.html b/src/main/resources/static/gr-serviceuser-ssh-panel.html
new file mode 100644
index 0000000..b7ed896
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-ssh-panel.html
@@ -0,0 +1,132 @@
+<!--
+@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.
+-->
+
+<dom-module id="gr-serviceuser-ssh-panel">
+  <template>
+    <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-tap="_showKey"
+                             data-index$="[[index]]"
+                             link>Click to View</gr-button>
+                </td>
+                <td>
+                  <gr-button link
+                             data-index$="[[index]]"
+                             on-tap="_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-tap="_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-tap="_handleAddKey">
+          Add new SSH key
+        </gr-button>
+      </fieldset>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-serviceuser-ssh-panel.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-serviceuser-ssh-panel.js b/src/main/resources/static/gr-serviceuser-ssh-panel.js
new file mode 100644
index 0000000..59f307f
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser-ssh-panel.js
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+(function() {
+  'use strict';
+
+  const JSON_PREFIX = ')]}\'';
+
+  Polymer({
+    is: 'gr-serviceuser-ssh-panel',
+    _legacyUndefinedCheck: true,
+
+    properties: {
+      _restApi: Object,
+      _serviceUser: Object,
+      _keys: Array,
+      /** @type {?} */
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+    },
+
+    loadData(restApi, serviceUser) {
+      this._restApi = restApi;
+      this._serviceUser = serviceUser;
+      return this._restApi.get(`${this._serviceUser._account_id}/sshkeys`)
+          .then(keys => {
+            if (!keys) {
+              this._keys = [];
+              return;
+            }
+            this._keys = keys;
+          });
+    },
+
+    _getStatusLabel(isValid) {
+      return isValid ? 'Valid' : 'Invalid';
+    },
+
+    _showKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      this._keyToView = this._keys[index];
+      this.$.viewKeyOverlay.open();
+    },
+
+    _closeOverlay() {
+      this.$.viewKeyOverlay.close();
+    },
+
+    _handleDeleteKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      this.push('_keysToRemove', this._keys[index]);
+
+      const promises = this._keysToRemove.map(key => {
+        this._restApi.delete(`${this._serviceUser._account_id}/sshkeys/${key.seq}`);
+      });
+
+      return Promise.all(promises).then(() => {
+        this.splice('_keys', index, 1);
+        this._keysToRemove = [];
+      });
+    },
+
+    _handleAddKey() {
+      this.$.addButton.disabled = true;
+      this.$.newKey.disabled = true;
+      return this._restApi.post(`${this._serviceUser._account_id}/sshkeys`,
+          this._newKey.trim(), 'plain/text')
+        .then(key => {
+          this.push('_keys', key);
+        }).catch(() => {
+          this.$.addButton.disabled = false;
+          this.$.newKey.disabled = false;
+        });
+    },
+
+    _computeAddButtonDisabled(newKey) {
+      return !newKey.length;
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-serviceuser.html b/src/main/resources/static/gr-serviceuser.html
new file mode 100644
index 0000000..92714a2
--- /dev/null
+++ b/src/main/resources/static/gr-serviceuser.html
@@ -0,0 +1,46 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+
+<link rel="import"
+      href="./gr-serviceuser-list.html">
+<link rel="import"
+      href="./gr-serviceuser-detail.html">
+<link rel="import"
+      href="./gr-serviceuser-create.html">
+
+<dom-module id="gr-serviceuser">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.restApi('/accounts/self/capabilities/').get('')
+          .then(capabilities => {
+            if (capabilities
+                && (capabilities.administrateServer
+                    || capabilities['serviceuser-createServiceUser'])) {
+              plugin.screen('list', 'gr-serviceuser-list');
+              plugin.screen('user', 'gr-serviceuser-detail');
+              plugin.screen('create', 'gr-serviceuser-create');
+            }
+            plugin.admin()
+              .addMenuLink(
+                'Service Users',
+                '/x/serviceuser/list',
+                'serviceuser-createServiceUser');
+          });
+    });
+  </script>
+</dom-module>