Add settings screen for editing GPG public keys

The UI is almost identical to the UI for editing SSH keys (although
implemented with UiBinder).

Change-Id: Ic6cf4dc9d7f71b00efea00a86498660d113ebb2b
This commit is contained in:
Dave Borowitz 2015-08-03 10:28:32 -07:00
parent eab3aff03a
commit 0349f8a1c8
12 changed files with 499 additions and 14 deletions

View File

@ -26,6 +26,7 @@ public class PageLinks {
public static final String SETTINGS = "/settings/";
public static final String SETTINGS_PREFERENCES = "/settings/preferences";
public static final String SETTINGS_SSHKEYS = "/settings/ssh-keys";
public static final String SETTINGS_GPGKEYS = "/settings/gpg-keys";
public static final String SETTINGS_HTTP_PASSWORD = "/settings/http-password";
public static final String SETTINGS_WEBIDENT = "/settings/web-identities";
public static final String SETTINGS_MYGROUPS = "/settings/group-memberships";

View File

@ -91,6 +91,20 @@ public class Natives {
return arr;
}
public static JsArrayString arrayOf(Iterable<String> elements) {
JsArrayString arr = JavaScriptObject.createArray().cast();
for (String elem : elements) {
arr.push(elem);
}
return arr;
}
public static JsArrayString arrayOf(String element) {
JsArrayString arr = JavaScriptObject.createArray().cast();
arr.push(element);
return arr;
}
private Natives() {
}
}

View File

@ -29,6 +29,7 @@ import static com.google.gerrit.common.PageLinks.SETTINGS;
import static com.google.gerrit.common.PageLinks.SETTINGS_AGREEMENTS;
import static com.google.gerrit.common.PageLinks.SETTINGS_CONTACT;
import static com.google.gerrit.common.PageLinks.SETTINGS_EXTENSION;
import static com.google.gerrit.common.PageLinks.SETTINGS_GPGKEYS;
import static com.google.gerrit.common.PageLinks.SETTINGS_HTTP_PASSWORD;
import static com.google.gerrit.common.PageLinks.SETTINGS_MYGROUPS;
import static com.google.gerrit.common.PageLinks.SETTINGS_NEW_AGREEMENT;
@ -40,6 +41,7 @@ import static com.google.gerrit.common.PageLinks.toChangeQuery;
import com.google.gerrit.client.account.MyAgreementsScreen;
import com.google.gerrit.client.account.MyContactInformationScreen;
import com.google.gerrit.client.account.MyGpgKeysScreen;
import com.google.gerrit.client.account.MyGroupsScreen;
import com.google.gerrit.client.account.MyIdentitiesScreen;
import com.google.gerrit.client.account.MyPasswordScreen;
@ -536,6 +538,10 @@ public class Dispatcher {
return new MySshKeysScreen();
}
if (matchExact(SETTINGS_GPGKEYS, token)) {
return new MyGpgKeysScreen();
}
if (matchExact(SETTINGS_WEBIDENT, token)) {
return new MyIdentitiesScreen();
}

View File

@ -184,6 +184,7 @@ public interface GerritCss extends CssResource {
String sshHostKeyPanelKnownHostEntry();
String sshKeyPanelEncodedKey();
String sshKeyPanelInvalid();
String sshKeyTable();
String stringListPanelButtons();
String topMostCell();
String topmenu();

View File

@ -17,10 +17,13 @@ package com.google.gerrit.client.account;
import com.google.gerrit.client.VoidResult;
import com.google.gerrit.client.info.AccountInfo;
import com.google.gerrit.client.rpc.CallbackGroup;
import com.google.gerrit.client.rpc.NativeMap;
import com.google.gerrit.client.rpc.NativeString;
import com.google.gerrit.client.rpc.Natives;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.user.client.rpc.AsyncCallback;
import java.util.Set;
@ -147,4 +150,42 @@ public class AccountApi {
protected UsernameInput() {
}
}
public static void addGpgKey(String account, String armored,
AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
new RestApi("/accounts/")
.id(account)
.view("gpgkeys")
.post(GpgKeysInput.add(armored), cb);
}
public static void removeGpgKeys(String account,
Iterable<String> fingerprints, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
new RestApi("/accounts/")
.id(account)
.view("gpgkeys")
.post(GpgKeysInput.remove(fingerprints), cb);
}
private static class GpgKeysInput extends JavaScriptObject {
static GpgKeysInput add(String key) {
return createAdd(Natives.arrayOf(key));
}
static GpgKeysInput remove(Iterable<String> fingerprints) {
return createRemove(Natives.arrayOf(fingerprints));
}
private static native GpgKeysInput createAdd(JsArrayString keys) /*-{
return {'add': keys};
}-*/;
private static native GpgKeysInput createRemove(
JsArrayString fingerprints) /*-{
return {'remove': fingerprints};
}-*/;
protected GpgKeysInput() {
}
}
}

View File

@ -50,14 +50,15 @@ public interface AccountConstants extends Constants {
String myMenuReset();
String tabAccountSummary();
String tabPreferences();
String tabWatchedProjects();
String tabContactInformation();
String tabSshKeys();
String tabHttpAccess();
String tabWebIdentities();
String tabMyGroups();
String tabAgreements();
String tabContactInformation();
String tabGpgKeys();
String tabHttpAccess();
String tabMyGroups();
String tabPreferences();
String tabSshKeys();
String tabWatchedProjects();
String tabWebIdentities();
String buttonShowAddSshKey();
String buttonCloseAddSshKey();
@ -94,6 +95,10 @@ public interface AccountConstants extends Constants {
String sshHostKeyFingerprint();
String sshHostKeyKnownHostEntry();
String gpgKeyId();
String gpgKeyFingerprint();
String gpgKeyUserIds();
String webIdStatus();
String webIdEmail();
String webIdIdentity();

View File

@ -36,14 +36,15 @@ changeScreenOldUi = Old Screen
changeScreenNewUi = New Screen
tabAccountSummary = Profile
tabPreferences = Preferences
tabWatchedProjects = Watched Projects
tabContactInformation = Contact Information
tabSshKeys = SSH Public Keys
tabHttpAccess = HTTP Password
tabWebIdentities = Identities
tabMyGroups = Groups
tabAgreements = Agreements
tabContactInformation = Contact Information
tabGpgKeys = GPG Public Keys
tabHttpAccess = HTTP Password
tabMyGroups = Groups
tabPreferences = Preferences
tabSshKeys = SSH Public Keys
tabWatchedProjects = Watched Projects
tabWebIdentities = Identities
buttonShowAddSshKey = Add Key ...
buttonCloseAddSshKey = Close
@ -73,6 +74,10 @@ sshHostKeyTitle = Server Host Key
sshHostKeyFingerprint = Fingerprint:
sshHostKeyKnownHostEntry = Entry for <code>~/.ssh/known_hosts</code>:
gpgKeyId = ID
gpgKeyFingerprint = Fingerprint
gpgKeyUserIds = User IDs
webIdStatus = Status
webIdEmail = Email Address
webIdIdentity = Identity

View File

@ -0,0 +1,28 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.client.account;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArrayString;
public class GpgKeyInfo extends JavaScriptObject {
public final native String id() /*-{ return this.id; }-*/;
public final native String fingerprint() /*-{ return this.fingerprint; }-*/;
public final native JsArrayString userIds() /*-{ return this.user_ids; }-*/;
public final native String key() /*-{ return this.key; }-*/;
protected GpgKeyInfo() {
}
}

View File

@ -0,0 +1,283 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.client.account;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.rpc.NativeMap;
import com.google.gerrit.client.rpc.Natives;
import com.google.gerrit.client.ui.FancyFlexTable;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.http.client.Response;
import com.google.gwt.uibinder.client.UiBinder;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.uibinder.client.UiHandler;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.rpc.StatusCodeException;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.CheckBox;
import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
import com.google.gwt.user.client.ui.HTMLPanel;
import com.google.gwt.user.client.ui.InlineLabel;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwtexpui.clippy.client.CopyableLabel;
import com.google.gwtexpui.globalkey.client.NpTextArea;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class MyGpgKeysScreen extends SettingsScreen {
interface Binder extends UiBinder<HTMLPanel, MyGpgKeysScreen> {}
private static final Binder uiBinder = GWT.create(Binder.class);
@UiField(provided = true) GpgKeyTable keys;
@UiField Button deleteKey;
@UiField Button addKey;
@UiField VerticalPanel addKeyBlock;
@UiField NpTextArea keyText;
@UiField VerticalPanel errorPanel;
@UiField Label errorText;
@UiField Button clearButton;
@UiField Button addButton;
@UiField Button closeButton;
@Override
protected void onInitUI() {
super.onInitUI();
keys = new GpgKeyTable();
add(uiBinder.createAndBindUi(this));
keys.updateDeleteButton();
}
@Override
protected void onLoad() {
super.onLoad();
refreshKeys();
}
@UiHandler("deleteKey")
void onDeleteKey(@SuppressWarnings("unused") ClickEvent e) {
keys.deleteChecked();
}
@UiHandler("addKey")
void onAddKey(@SuppressWarnings("unused") ClickEvent e) {
showAddKeyBlock(true);
}
@UiHandler("clearButton")
void onClearButton(@SuppressWarnings("unused") ClickEvent e) {
keyText.setText("");
keyText.setFocus(true);
errorPanel.setVisible(false);
}
@UiHandler("closeButton")
void onCloseButton(@SuppressWarnings("unused") ClickEvent e) {
showAddKeyBlock(false);
}
@UiHandler("addButton")
void onAddButton(@SuppressWarnings("unused") ClickEvent e) {
doAddKey();
}
private void refreshKeys() {
AccountApi.self().view("gpgkeys").get(NativeMap.copyKeysIntoChildren("id",
new GerritCallback<NativeMap<GpgKeyInfo>>() {
@Override
public void onSuccess(NativeMap<GpgKeyInfo> result) {
List<GpgKeyInfo> list = Natives.asList(result.values());
// TODO(dborowitz): Sort on something more meaningful, like
// created date?
Collections.sort(list, new Comparator<GpgKeyInfo>() {
@Override
public int compare(GpgKeyInfo a, GpgKeyInfo b) {
return a.id().compareTo(b.id());
}
});
keys.clear();
keyText.setText("");
errorPanel.setVisible(false);
addButton.setEnabled(true);
if (!list.isEmpty()) {
keys.setVisible(true);
for (GpgKeyInfo k : list) {
keys.addOneKey(k);
}
showKeyTable(true);
showAddKeyBlock(false);
} else {
keys.setVisible(false);
showAddKeyBlock(true);
showKeyTable(false);
}
display();
}
}));
}
private void showAddKeyBlock(boolean show) {
addKey.setVisible(!show);
addKeyBlock.setVisible(show);
}
private void showKeyTable(boolean show) {
keys.setVisible(show);
deleteKey.setVisible(show);
addKey.setVisible(show);
}
private void doAddKey() {
if (keyText.getText().isEmpty()) {
return;
}
addButton.setEnabled(false);
keyText.setEnabled(false);
AccountApi.addGpgKey("self", keyText.getText(),
new AsyncCallback<NativeMap<GpgKeyInfo>>() {
@Override
public void onSuccess(NativeMap<GpgKeyInfo> result) {
keyText.setEnabled(true);
refreshKeys();
}
@Override
public void onFailure(Throwable caught) {
keyText.setEnabled(true);
addButton.setEnabled(true);
if (caught instanceof StatusCodeException) {
StatusCodeException sce = (StatusCodeException) caught;
if (sce.getStatusCode() == Response.SC_CONFLICT
|| sce.getStatusCode() == Response.SC_BAD_REQUEST) {
errorText.setText(sce.getEncodedResponse());
} else {
errorText.setText(sce.getMessage());
}
} else {
errorText.setText(
"Unexpected error saving key: " + caught.getMessage());
}
errorPanel.setVisible(true);
}
});
}
private class GpgKeyTable extends FancyFlexTable<GpgKeyInfo> {
private final ValueChangeHandler<Boolean> updateDeleteHandler;
GpgKeyTable() {
table.setWidth("");
table.setText(0, 1, Util.C.gpgKeyId());
table.setText(0, 2, Util.C.gpgKeyFingerprint());
table.setText(0, 3, Util.C.gpgKeyUserIds());
FlexCellFormatter fmt = table.getFlexCellFormatter();
fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().iconHeader());
fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
updateDeleteHandler = new ValueChangeHandler<Boolean>() {
@Override
public void onValueChange(ValueChangeEvent<Boolean> event) {
updateDeleteButton();
}
};
}
private void addOneKey(GpgKeyInfo k) {
int row = table.getRowCount();
table.insertRow(row);
applyDataRowStyle(row);
CheckBox sel = new CheckBox();
sel.addValueChangeHandler(updateDeleteHandler);
table.setWidget(row, 0, sel);
table.setWidget(row, 1, new CopyableLabel(k.id()));
table.setText(row, 2, k.fingerprint());
VerticalPanel userIds = new VerticalPanel();
for (int i = 0; i < k.userIds().length(); i++) {
userIds.add(new InlineLabel(k.userIds().get(i)));
}
table.setWidget(row, 3, userIds);
FlexCellFormatter fmt = table.getFlexCellFormatter();
fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
setRowItem(row, k);
}
private void updateDeleteButton() {
for (int row = 1; row < table.getRowCount(); row++) {
if (isChecked(row)) {
deleteKey.setEnabled(true);
return;
}
}
deleteKey.setEnabled(false);
}
private void deleteChecked() {
deleteKey.setEnabled(false);
List<String> toDelete = new ArrayList<>(table.getRowCount());
for (int row = 1; row < table.getRowCount(); row++) {
if (isChecked(row)) {
toDelete.add(getRowItem(row).fingerprint());
}
}
AccountApi.removeGpgKeys("self", toDelete,
new GerritCallback<NativeMap<GpgKeyInfo>>() {
@Override
public void onSuccess(NativeMap<GpgKeyInfo> result) {
refreshKeys();
}
@Override
public void onFailure(Throwable caught) {
deleteKey.setEnabled(true);
super.onFailure(caught);
}
});
}
private boolean isChecked(int row) {
return ((CheckBox) table.getWidget(row, 0)).getValue();
}
private void clear() {
while (table.getRowCount() > 1) {
table.removeRow(1);
}
for (int i = table.getRowCount() - 1; i >= 1; i++) {
table.removeRow(i);
}
}
}
}

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2015 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.
-->
<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
xmlns:g='urn:import:com.google.gwt.user.client.ui'
xmlns:expui='urn:import:com.google.gwtexpui.globalkey.client'>
<ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
<ui:style gss='false'>
.errorHeader {
font-weight: bold;
}
.errorText {
white-space: pre-wrap;
padding-bottom: 6px;
}
</ui:style>
<g:HTMLPanel>
<g:Widget ui:field='keys' addStyleNames='{res.css.sshKeyTable}'/>
<g:FlowPanel>
<g:Button ui:field='deleteKey'>
<div><ui:msg>Delete</ui:msg></div>
</g:Button>
<g:Button ui:field='addKey'>
<div><ui:msg>Add Key ...</ui:msg></div>
</g:Button>
</g:FlowPanel>
<g:VerticalPanel ui:field='addKeyBlock'
styleName='{res.css.addSshKeyPanel}'
visible='false'>
<g:Label>Add GPG Public Key</g:Label>
<g:DisclosurePanel>
<g:header>How to generate a GPG key</g:header>
<g:HTMLPanel>
<ol>
<li>
From the Terminal or Git Bash, run <em>gpg --gen-key</em> and
follow the prompts to create the key.
</li>
<li>
Use the default kind. Use the default (or higher) keysize. Choose
any value for your expiration.
</li>
<li>
The user ID should contain one of your registered email addresses.
</li>
<li>Setting a passphrase is strongly recommended.</li>
<li>Note the ID of your new key.</li>
<li>
To export your key, run the following and paste the full output
into the text box:
<br/>
<code>gpg --export -a &lt;key ID&gt;</code>
</li>
</ol>
</g:HTMLPanel>
</g:DisclosurePanel>
<expui:NpTextArea
visibleLines='12'
characterWidth='80'
spellCheck='false'
ui:field='keyText'/>
<g:VerticalPanel ui:field='errorPanel' visible='false'>
<g:Label styleName='{style.errorHeader}'>Error adding GPG key:</g:Label>
<g:Label styleName='{style.errorText}' ui:field='errorText'/>
</g:VerticalPanel>
<g:FlowPanel>
<g:Button ui:field='clearButton'>
<div><ui:msg>Clear</ui:msg></div>
</g:Button>
<g:Button ui:field='addButton'>
<div><ui:msg>Add</ui:msg></div>
</g:Button>
<g:Button ui:field='closeButton'>
<div><ui:msg>Close</ui:msg></div>
</g:Button>
</g:FlowPanel>
</g:VerticalPanel>
</g:HTMLPanel>
</ui:UiBinder>

View File

@ -45,6 +45,9 @@ public abstract class SettingsScreen extends MenuScreen {
if (Gerrit.info().auth().isHttpPasswordSettingsEnabled()) {
linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
}
if (Gerrit.info().receive().enableSignedPush()) {
linkByGerrit(Util.C.tabGpgKeys(), PageLinks.SETTINGS_GPGKEYS);
}
linkByGerrit(Util.C.tabWebIdentities(), PageLinks.SETTINGS_WEBIDENT);
linkByGerrit(Util.C.tabMyGroups(), PageLinks.SETTINGS_MYGROUPS);
if (Gerrit.info().auth().useContributorAgreements()) {

View File

@ -1074,6 +1074,10 @@ a:hover.downloadLink {
width: 100%;
}
.sshKeyTable td.dataCell, .sshKeyTable td.iconCell {
vertical-align: top;
}
.createProjectPanel {
margin-bottom: 10px;
background-color: trimColor;