| /** | |
| * @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.js'; | |
| import {css, CSSResult, html, LitElement, PropertyValues} from 'lit'; | |
| import {RestPluginApi} from '@gerritcodereview/typescript-api/rest'; | |
| //TODO: Remove when it is released with typescript API | |
| export declare interface GrAutogrowTextarea extends HTMLElement { | |
| value?: string; | |
| nativeElement?: HTMLElement; | |
| placeholder?: string; | |
| setRangeText: (replacement: string, start: number, end: number) => void; | |
| } | |
| 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; | |
| } | |
| gr-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"> | |
| <gr-autogrow-textarea | |
| id="newKey" | |
| autocomplete="on" | |
| placeholder="New SSH Key" | |
| .value=${this.newKey} | |
| @input=${(e: BindValueChangeEvent) => { | |
| const value = (e.target as GrAutogrowTextarea).value ?? ''; | |
| this.newKey = value; | |
| }} | |
| ></gr-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; | |
| }); | |
| } | |
| } |