blob: 74b6a97d98b687d2bca4fb33997c2b0466f4dd80 [file] [log] [blame]
Thomas Dräbing6acb3c12023-11-10 13:57:07 +01001/**
2 * @license
3 * Copyright (C) 2019 The Android Open Source Project
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18import {customElement, property, query, state} from 'lit/decorators';
19import {css, CSSResult, html, LitElement, PropertyValues} from 'lit';
20import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
21
22export interface BindValueChangeEventDetail {
23 value: string | undefined;
24}
25export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
26
27// TODO: Remove when it is released with typescript API
28export interface SshKeyInfo {
29 seq: number;
30 ssh_public_key: string;
31 encoded_key: string;
32 algorithm: string;
33 comment?: string;
34 valid: boolean;
35}
36
37@customElement('gr-serviceuser-ssh-panel')
38export class GrServiceUserSshPanel extends LitElement {
39 @query('#addButton') addButton!: HTMLButtonElement;
40
41 @query('#newKey') newKeyEditor!: HTMLTextAreaElement;
42
43 @query('#viewKeyModal') viewKeyModal!: HTMLDialogElement;
44
45 @property({type: Boolean})
46 hasUnsavedChanges = false;
47
48 @property()
49 pluginRestApi!: RestPluginApi;
50
51 @property({type: String})
52 serviceUserId?: String;
53
54 @property({type: Array})
55 keys: SshKeyInfo[] = [];
56
57 @property({type: Object})
58 keyToView?: SshKeyInfo;
59
60 @property({type: String})
61 newKey = '';
62
63 @property({type: Array})
64 keysToRemove: SshKeyInfo[] = [];
65
66 @state() prevHasUnsavedChanges = false;
67
68 static override get styles() {
69 return [
70 window.Gerrit.styles.form as CSSResult,
71 window.Gerrit.styles.modal as CSSResult,
72 css`
73 .statusHeader {
74 width: 4em;
75 }
76 .keyHeader {
77 width: 7.5em;
78 }
79 #viewKeyModal {
80 padding: var(--spacing-xxl);
81 width: 50em;
82 }
83 .publicKey {
84 font-family: var(--monospace-font-family);
85 font-size: var(--font-size-mono);
86 line-height: var(--line-height-mono);
87 overflow-x: scroll;
88 overflow-wrap: break-word;
89 width: 30em;
90 }
91 .closeButton {
92 bottom: 2em;
93 position: absolute;
94 right: 2em;
95 }
96 #existing {
97 margin-bottom: var(--spacing-l);
98 }
99 #existing .commentColumn {
100 min-width: 27em;
101 width: auto;
102 }
103 iron-autogrow-textarea {
104 background-color: var(--view-background-color);
105 }
106 `,
107 ];
108 }
109
110 override updated(changedProperties: PropertyValues) {
111 if (changedProperties.has('hasUnsavedChanges')) {
112 if (this.prevHasUnsavedChanges === this.hasUnsavedChanges) return;
113 this.prevHasUnsavedChanges = this.hasUnsavedChanges;
114 this.dispatchEvent(
115 new CustomEvent('has-unsaved-changes-changed', {
116 detail: {value: this.hasUnsavedChanges},
117 composed: true,
118 bubbles: true,
119 })
120 );
121 }
122 }
123
124 override render() {
125 return html`
126 <div class="gr-form-styles">
127 <fieldset id="existing">
128 <table>
129 <thead>
130 <tr>
131 <th class="commentColumn">Comment</th>
132 <th class="statusHeader">Status</th>
133 <th class="keyHeader">Public key</th>
134 <th></th>
135 <th></th>
136 </tr>
137 </thead>
138 <tbody>
139 ${this.keys.map((key, index) => this.renderKey(key, index))}
140 </tbody>
141 </table>
142 <dialog id="viewKeyModal" tabindex="-1">
143 <fieldset>
144 <section>
145 <span class="title">Algorithm</span>
146 <span class="value">${this.keyToView?.algorithm}</span>
147 </section>
148 <section>
149 <span class="title">Public key</span>
150 <span class="value publicKey"
151 >${this.keyToView?.encoded_key}</span
152 >
153 </section>
154 <section>
155 <span class="title">Comment</span>
156 <span class="value">${this.keyToView?.comment}</span>
157 </section>
158 </fieldset>
159 <gr-button
160 class="closeButton"
161 @click=${() => this.viewKeyModal.close()}
162 >Close</gr-button
163 >
164 </dialog>
165 <gr-button
166 @click=${() => this.save()}
167 ?disabled=${!this.hasUnsavedChanges}
168 >Save changes</gr-button
169 >
170 </fieldset>
171 <fieldset>
172 <section>
173 <span class="title">New SSH key</span>
174 <span class="value">
175 <iron-autogrow-textarea
176 id="newKey"
177 autocomplete="on"
178 placeholder="New SSH Key"
179 .bindValue=${this.newKey}
180 @bind-value-changed=${(e: BindValueChangeEvent) => {
181 this.newKey = e.detail.value ?? '';
182 }}
183 ></iron-autogrow-textarea>
184 </span>
185 </section>
186 <gr-button
187 id="addButton"
188 link=""
189 ?disabled=${!this.newKey.length}
190 @click=${() => this.handleAddKey()}
191 >Add new SSH key</gr-button
192 >
193 </fieldset>
194 </div>
195 `;
196 }
197
198 private renderKey(key: SshKeyInfo, index: number) {
199 return html` <tr>
200 <td class="commentColumn">${key.comment}</td>
201 <td>${key.valid ? 'Valid' : 'Invalid'}</td>
202 <td>
203 <gr-button
204 link=""
205 @click=${(e: Event) => this.showKey(e)}
206 data-index=${index}
207 >Click to View</gr-button
208 >
209 </td>
210 <td>
211 <gr-copy-clipboard
212 hasTooltip=""
213 .buttonTitle=${'Copy SSH public key to clipboard'}
214 hideInput=""
215 .text=${key.ssh_public_key}
216 >
217 </gr-copy-clipboard>
218 </td>
219 <td>
220 <gr-button
221 link=""
222 data-index=${index}
223 @click=${(e: Event) => this.handleDeleteKey(e)}
224 >Delete</gr-button
225 >
226 </td>
227 </tr>`;
228 }
229
230 loadData(pluginRestApi: RestPluginApi) {
231 this.pluginRestApi = pluginRestApi;
232 this.serviceUserId = this.baseURI.split('/').pop();
233 return this.pluginRestApi
234 .get<Array<SshKeyInfo>>(
235 `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys`
236 )
237 .then(keys => {
238 if (!keys) {
239 this.keys = [];
240 return;
241 }
242 this.keys = keys;
243 });
244 }
245
246 private save() {
247 const promises = this.keysToRemove.map(key =>
248 this.pluginRestApi.delete(
249 `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys/${key.seq}`
250 )
251 );
252 return Promise.all(promises).then(() => {
253 this.keysToRemove = [];
254 this.hasUnsavedChanges = false;
255 });
256 }
257
258 private showKey(e: Event) {
259 const el = e.target as HTMLBaseElement;
260 const index = Number(el.getAttribute('data-index'));
261 this.keyToView = this.keys[index];
262 this.viewKeyModal.showModal();
263 }
264
265 private handleDeleteKey(e: Event) {
266 const el = e.target as HTMLBaseElement;
267 const index = Number(el.getAttribute('data-index')!);
268 this.keysToRemove.push(this.keys[index]);
269 this.keys.splice(index, 1);
270 this.requestUpdate();
271 this.hasUnsavedChanges = true;
272 }
273
274 private handleAddKey() {
275 this.addButton.disabled = true;
276 this.newKeyEditor.disabled = true;
277 return this.pluginRestApi
278 .post<SshKeyInfo>(
279 `/config/server/serviceuser~serviceusers/${this.serviceUserId}/sshkeys`,
280 this.newKey.trim(),
281 undefined,
282 'plain/text'
283 )
284 .then(key => {
285 this.newKeyEditor.disabled = false;
286 this.newKey = '';
287 this.keys.push(key);
288 this.requestUpdate();
289 })
290 .catch(error => {
291 this.dispatchEvent(
292 new CustomEvent('show-error', {
293 detail: {message: error},
294 composed: true,
295 bubbles: true,
296 })
297 );
298 })
299 .finally(() => {
300 this.addButton.disabled = false;
301 this.newKeyEditor.disabled = false;
302 });
303 }
304}