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"
+ ],
+}