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