Merged #64 "Editable user profile page"
diff --git a/releases.moxie b/releases.moxie
index 7635e06..c7299a7 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -34,6 +34,8 @@
     - Added CRUD functionality for Ticket Milestones (ticket-17)
     - Implemented Ticket migration tool to move between backends (ticket-19)
     - Added extension points for top nav links, root-level pages, repository nav links, user menu links, and http request filters (ticket-23)
+    - Added an editor panel in the user profile page to manipulate preferences (issue-108, issue-424, ticket-64)
+    - Added an editor panel in the user profile page to manipulate public SSH keys (ticket-64)
     - Add FORK_REPOSITORY RPC request type (issue-371, pr-161, ticket-65)
     - Add object type (ot) parameter for RSS queries to retrieve tag details (pr-165, ticket-66)
     - Add setting to allow STARTTLS without requiring SMTPS (pr-183)
diff --git a/src/main/java/com/gitblit/ConfigUserService.java b/src/main/java/com/gitblit/ConfigUserService.java
index 9b4dd7f..9759eff 100644
--- a/src/main/java/com/gitblit/ConfigUserService.java
+++ b/src/main/java/com/gitblit/ConfigUserService.java
@@ -96,6 +96,8 @@
 

 	private static final String LOCALE = "locale";

 

+	private static final String EMAILONMYTICKETCHANGES = "emailMeOnMyTicketChanges";

+

 	private static final String ACCOUNTTYPE = "accountType";

 

 	private static final String DISABLED = "disabled";

@@ -707,9 +709,11 @@
 				config.setBoolean(USER, model.username, DISABLED, true);

 			}

 			if (model.getPreferences() != null) {

-				if (!StringUtils.isEmpty(model.getPreferences().locale)) {

-					config.setString(USER, model.username, LOCALE, model.getPreferences().locale);

+				if (model.getPreferences().getLocale() != null) {

+					String val = model.getPreferences().getLocale().getLanguage() + "_" + model.getPreferences().getLocale().getCountry();

+					config.setString(USER, model.username, LOCALE, val);

 				}

+				config.setBoolean(USER, model.username, EMAILONMYTICKETCHANGES, model.getPreferences().isEmailMeOnMyTicketChanges());

 			}

 

 			// user roles

@@ -880,11 +884,14 @@
 					user.stateProvince = config.getString(USER, username, STATEPROVINCE);

 					user.countryCode = config.getString(USER, username, COUNTRYCODE);

 					user.cookie = config.getString(USER, username, COOKIE);

-					user.getPreferences().locale = config.getString(USER, username, LOCALE);

 					if (StringUtils.isEmpty(user.cookie) && !StringUtils.isEmpty(user.password)) {

 						user.cookie = StringUtils.getSHA1(user.username + user.password);

 					}

 

+					// preferences

+					user.getPreferences().setLocale(config.getString(USER, username, LOCALE));

+					user.getPreferences().setEmailMeOnMyTicketChanges(config.getBoolean(USER, username, EMAILONMYTICKETCHANGES, true));

+

 					// user roles

 					Set<String> roles = new HashSet<String>(Arrays.asList(config.getStringList(

 							USER, username, ROLE)));

diff --git a/src/main/java/com/gitblit/FederationClient.java b/src/main/java/com/gitblit/FederationClient.java
index c3dcd9d..cd06c3c 100644
--- a/src/main/java/com/gitblit/FederationClient.java
+++ b/src/main/java/com/gitblit/FederationClient.java
@@ -166,6 +166,11 @@
 		}

 

 		@Override

+		public boolean isSendingMail() {

+			return false;

+		}

+

+		@Override

 		public void sendMailToAdministrators(String subject, String message) {

 		}

 

diff --git a/src/main/java/com/gitblit/GitBlit.java b/src/main/java/com/gitblit/GitBlit.java
index 3db5f08..8179385 100644
--- a/src/main/java/com/gitblit/GitBlit.java
+++ b/src/main/java/com/gitblit/GitBlit.java
@@ -117,6 +117,21 @@
 		return servicesManager.isServingRepositories();
 	}
 
+	@Override
+	public boolean isServingHTTP() {
+		return servicesManager.isServingHTTP();
+	}
+
+	@Override
+	public boolean isServingGIT() {
+		return servicesManager.isServingGIT();
+	}
+
+	@Override
+	public boolean isServingSSH() {
+		return servicesManager.isServingSSH();
+	}
+
 	protected Object [] getModules() {
 		return new Object [] { new GitBlitModule()};
 	}
diff --git a/src/main/java/com/gitblit/manager/GitblitManager.java b/src/main/java/com/gitblit/manager/GitblitManager.java
index 16c71ba..98ad33e 100644
--- a/src/main/java/com/gitblit/manager/GitblitManager.java
+++ b/src/main/java/com/gitblit/manager/GitblitManager.java
@@ -602,6 +602,21 @@
 	}
 
 	@Override
+	public boolean isServingHTTP() {
+		return runtimeManager.isServingHTTP();
+	}
+
+	@Override
+	public boolean isServingGIT() {
+		return runtimeManager.isServingGIT();
+	}
+
+	@Override
+	public boolean isServingSSH() {
+		return runtimeManager.isServingSSH();
+	}
+
+	@Override
 	public TimeZone getTimezone() {
 		return runtimeManager.getTimezone();
 	}
@@ -646,6 +661,11 @@
 	 */
 
 	@Override
+	public boolean isSendingMail() {
+		return notificationManager.isSendingMail();
+	}
+
+	@Override
 	public void sendMailToAdministrators(String subject, String message) {
 		notificationManager.sendMailToAdministrators(subject, message);
 	}
diff --git a/src/main/java/com/gitblit/manager/INotificationManager.java b/src/main/java/com/gitblit/manager/INotificationManager.java
index 231cf43..64fc01e 100644
--- a/src/main/java/com/gitblit/manager/INotificationManager.java
+++ b/src/main/java/com/gitblit/manager/INotificationManager.java
@@ -22,6 +22,14 @@
 public interface INotificationManager extends IManager {
 
 	/**
+	 * Returns true if the email service is configured and ready to send notifications.
+	 *
+	 * @return true if the email service is operational
+	 * @since 1.6.0
+	 */
+	boolean isSendingMail();
+
+	/**
 	 * Notify the administrators by email.
 	 *
 	 * @param subject
diff --git a/src/main/java/com/gitblit/manager/IRuntimeManager.java b/src/main/java/com/gitblit/manager/IRuntimeManager.java
index 29e7368..b2d7a2b 100644
--- a/src/main/java/com/gitblit/manager/IRuntimeManager.java
+++ b/src/main/java/com/gitblit/manager/IRuntimeManager.java
@@ -57,6 +57,33 @@
 	boolean isServingRepositories();
 
 	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over HTTP.
+	 *
+	 * @return true if Gitblit is serving repositories over HTTP
+ 	 * @since 1.6.0
+	 */
+	boolean isServingHTTP();
+
+	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over the GIT Daemon protocol.
+	 *
+	 * @return true if Gitblit is serving repositories over the GIT Daemon protocol
+ 	 * @since 1.6.0
+	 */
+	boolean isServingGIT();
+
+	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over the SSH protocol.
+	 *
+	 * @return true if Gitblit is serving repositories over the SSH protocol
+ 	 * @since 1.6.0
+	 */
+	boolean isServingSSH();
+
+	/**
 	 * Determine if this Gitblit instance is running in debug mode
 	 *
 	 * @return true if Gitblit is running in debug mode
diff --git a/src/main/java/com/gitblit/manager/NotificationManager.java b/src/main/java/com/gitblit/manager/NotificationManager.java
index ba63cfc..69a611b 100644
--- a/src/main/java/com/gitblit/manager/NotificationManager.java
+++ b/src/main/java/com/gitblit/manager/NotificationManager.java
@@ -71,6 +71,11 @@
 		return this;
 	}
 
+	@Override
+	public boolean isSendingMail() {
+		return mailService.isReady();
+	}
+
 	/**
 	 * Notify the administrators by email.
 	 *
diff --git a/src/main/java/com/gitblit/manager/RuntimeManager.java b/src/main/java/com/gitblit/manager/RuntimeManager.java
index 52f4d67..9cdc64e 100644
--- a/src/main/java/com/gitblit/manager/RuntimeManager.java
+++ b/src/main/java/com/gitblit/manager/RuntimeManager.java
@@ -119,9 +119,42 @@
 	 */
 	@Override
 	public boolean isServingRepositories() {
-		return settings.getBoolean(Keys.git.enableGitServlet, true)
-				|| (settings.getInteger(Keys.git.daemonPort, 0) > 0)
-				|| (settings.getInteger(Keys.git.sshPort, 0) > 0);
+		return isServingHTTP()
+				|| isServingGIT()
+				|| isServingSSH();
+	}
+
+	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over the HTTP protocol.
+	 *
+	 * @return true if Gitblit is serving repositories over the HTTP protocol
+	 */
+	@Override
+	public boolean isServingHTTP() {
+		return settings.getBoolean(Keys.git.enableGitServlet, true);
+	}
+
+	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over the Git Daemon protocol.
+	 *
+	 * @return true if Gitblit is serving repositories over the Git Daemon protocol
+	 */
+	@Override
+	public boolean isServingGIT() {
+		return settings.getInteger(Keys.git.daemonPort, 0) > 0;
+	}
+
+	/**
+	 * Determine if this Gitblit instance is actively serving git repositories
+	 * over the SSH protocol.
+	 *
+	 * @return true if Gitblit is serving repositories over the SSH protocol
+	 */
+	@Override
+	public boolean isServingSSH() {
+		return settings.getInteger(Keys.git.sshPort, 0) > 0;
 	}
 
 	/**
diff --git a/src/main/java/com/gitblit/manager/ServicesManager.java b/src/main/java/com/gitblit/manager/ServicesManager.java
index 755d8ba..3721578 100644
--- a/src/main/java/com/gitblit/manager/ServicesManager.java
+++ b/src/main/java/com/gitblit/manager/ServicesManager.java
@@ -112,9 +112,21 @@
 	}
 
 	public boolean isServingRepositories() {
-		return settings.getBoolean(Keys.git.enableGitServlet, true)
-				|| (gitDaemon != null && gitDaemon.isRunning())
-				|| (sshDaemon != null && sshDaemon.isRunning());
+		return isServingHTTP()
+				|| isServingGIT()
+				|| isServingSSH();
+	}
+
+	public boolean isServingHTTP() {
+		return settings.getBoolean(Keys.git.enableGitServlet, true);
+	}
+
+	public boolean isServingGIT() {
+		return gitDaemon != null && gitDaemon.isRunning();
+	}
+
+	public boolean isServingSSH() {
+		return sshDaemon != null && sshDaemon.isRunning();
 	}
 
 	protected void configureFederation() {
diff --git a/src/main/java/com/gitblit/models/UserPreferences.java b/src/main/java/com/gitblit/models/UserPreferences.java
index 44e4493..c95b0da 100644
--- a/src/main/java/com/gitblit/models/UserPreferences.java
+++ b/src/main/java/com/gitblit/models/UserPreferences.java
@@ -37,7 +37,9 @@
 
 	public final String username;
 
-	public String locale;
+	private String locale;
+
+	private Boolean emailMeOnMyTicketChanges;
 
 	private final Map<String, UserRepositoryPreferences> repositoryPreferences = new TreeMap<String, UserRepositoryPreferences>();
 
@@ -58,6 +60,10 @@
 		return new Locale(locale);
 	}
 
+	public void setLocale(String locale) {
+		this.locale = locale;
+	}
+
 	public UserRepositoryPreferences getRepositoryPreferences(String repositoryName) {
 		String key = repositoryName.toLowerCase();
 		if (!repositoryPreferences.containsKey(key)) {
@@ -96,4 +102,15 @@
 		Collections.sort(list);
 		return list;
 	}
+
+	public boolean isEmailMeOnMyTicketChanges() {
+		if (emailMeOnMyTicketChanges == null) {
+			return true;
+		}
+		return emailMeOnMyTicketChanges;
+	}
+
+	public void setEmailMeOnMyTicketChanges(boolean value) {
+		this.emailMeOnMyTicketChanges = value;
+	}
 }
diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java
index 9a5e4e1..07371b1 100644
--- a/src/main/java/com/gitblit/tickets/TicketNotifier.java
+++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java
@@ -545,7 +545,6 @@
 				}
 			}
 		}
-		mailing.setRecipients(toAddresses);
 
 		//
 		// CC recipients
@@ -554,7 +553,7 @@
 
 		// repository owners
 		if (!ArrayUtils.isEmpty(repository.owners)) {
-			tos.addAll(repository.owners);
+			ccs.addAll(repository.owners);
 		}
 
 		// cc users mentioned in last comment
@@ -595,6 +594,14 @@
 		}
 		ccAddresses.addAll(settings.getStrings(Keys.mail.mailingLists));
 
+		// respect the author's email preference
+		UserModel lastAuthor = userManager.getUserModel(lastChange.author);
+		if (!lastAuthor.getPreferences().isEmailMeOnMyTicketChanges()) {
+			toAddresses.remove(lastAuthor.emailAddress);
+			ccAddresses.remove(lastAuthor.emailAddress);
+		}
+
+		mailing.setRecipients(toAddresses);
 		mailing.setCCs(ccAddresses);
 	}
 
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
index d6fd57e..c80d45c 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -722,4 +722,21 @@
 gb.gc = GC 
 gb.garbageCollection = Garbage Collection
 gb.garbageCollectionDescription = The garbage collector will pack loose objects pushed from clients and will remove unreferenced objects from the repository.
-gb.commitMessageRendererDescription = Commit messages can be displayed as plaintext or as rendered markup.
\ No newline at end of file
+gb.commitMessageRendererDescription = Commit messages can be displayed as plaintext or as rendered markup.
+gb.preferences = preferences
+gb.accountPreferences = Account Preferences
+gb.accountPreferencesDescription = Specify your account preferences
+gb.languagePreference = Language Preference
+gb.languagePreferenceDescription = Select your preferred translation for Gitblit
+gb.emailMeOnMyTicketChanges = Email me on my ticket changes
+gb.emailMeOnMyTicketChangesDescription  = Send me an email notification for changes that I make to a ticket
+gb.displayNameDescription = The preferred name for display
+gb.emailAddressDescription = The primary email address for receiving notifications
+gb.sshKeys = SSH Keys
+gb.sshKeysDescription = SSH public key authentication is a secure alternative to password authentication
+gb.addSshKey = Add SSH Key
+gb.key = Key
+gb.comment = Comment
+gb.sshKeyCommentDescription = Enter an optional comment. If blank, the comment will be extracted from the key data.
+gb.permission = Permission
+gb.sshKeyPermissionDescription = Specify the access permission for the SSH key
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/UserPage.html b/src/main/java/com/gitblit/wicket/pages/UserPage.html
index 7aaded7..017fcb1 100644
--- a/src/main/java/com/gitblit/wicket/pages/UserPage.html
+++ b/src/main/java/com/gitblit/wicket/pages/UserPage.html
@@ -7,27 +7,20 @@
 <body>

 <wicket:extend>

 <div class="container">

-	<div class="row" style="padding-top:10px;">

-		<div class="span4">

-			<div wicket:id="gravatar"></div>

-			<div style="text-align: left;">

-				<h2><span wicket:id="userDisplayName"></span></h2>

-				<div><i class="icon-user"></i> <span wicket:id="userUsername"></span></div>

-				<div><i class="icon-envelope"></i><span wicket:id="userEmail"></span></div>

-			</div>

+	<div class="row" style="padding-top:15px;">

+		<div class="span3">

+			<div wicket:id="userTitlePanel"></div>

 		</div>

-		

-		<div class="span8">

-			<div class="pull-right">

-				<a class="btn-small" wicket:id="newRepository" style="padding-right:0px;">

-					<i class="icon icon-plus-sign"></i>

-					<wicket:message key="gb.newRepository"></wicket:message>

-				</a>

-			</div>

-			<div class="tabbable">

+	</div>

+	

+	<div class="row" style="padding-top:10px;">	

+		<div class="span12">

+			<div class="tabbable tabs-left">

 				<!-- tab titles -->

 				<ul class="nav nav-tabs">

 					<li class="active"><a href="#repositories" data-toggle="tab"><wicket:message key="gb.repositories"></wicket:message></a></li>

+					<div wicket:id="preferencesLink"></div>

+					<div wicket:id="sshKeysLink"></div>

 				</ul>

 	

 				<!-- tab content -->

@@ -41,11 +34,50 @@
 							</tbody>

 						</table>

 					</div>

+					

+					<!-- preferences tab -->

+					<div wicket:id="preferencesTab"></div>

+					

+					<!-- ssh keys tab -->

+					<div wicket:id="sshKeysTab"></div>

+					

 				</div>

 			</div>

 		</div>

 	</div>

 </div>

+

+<wicket:fragment wicket:id="preferencesLinkFragment">

+	<li><a href="#preferences" data-toggle="tab"><wicket:message key="gb.preferences"></wicket:message></a></li>

+</wicket:fragment>

+

+<wicket:fragment wicket:id="sshKeysLinkFragment">

+	<li><a href="#ssh" data-toggle="tab"><wicket:message key="gb.sshKeys"></wicket:message></a></li>

+</wicket:fragment>

+

+<wicket:fragment wicket:id="preferencesTabFragment">

+	<div class="tab-pane" id="preferences">

+		<h4><wicket:message key="gb.accountPreferences"></wicket:message></h4>

+		<p><wicket:message key="gb.accountPreferencesDescription"></wicket:message></p>

+		<hr />

+		

+		<form wicket:id="prefsForm">

+			<div wicket:id="displayName"></div>

+			<div wicket:id="emailAddress"></div>

+			<div wicket:id="language"></div>

+			<div wicket:id="emailMeOnMyTicketChanges"></div>

+			

+			<div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Save" wicket:message="value:gb.save" wicket:id="save" /></div>

+		</form>	

+	</div>

+</wicket:fragment>

+

+<wicket:fragment wicket:id="sshKeysTabFragment">

+	<div class="tab-pane" id="ssh">

+		<div wicket:id="sshKeysPanel"></div>		

+	</div>

+</wicket:fragment>

+

 </wicket:extend>

 </body>

 </html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/UserPage.java b/src/main/java/com/gitblit/wicket/pages/UserPage.java
index 29b49b3..00a3627 100644
--- a/src/main/java/com/gitblit/wicket/pages/UserPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/UserPage.java
@@ -15,19 +15,27 @@
  */

 package com.gitblit.wicket.pages;

 

+import java.io.Serializable;

 import java.util.ArrayList;

+import java.util.Arrays;

 import java.util.Collections;

 import java.util.Comparator;

 import java.util.List;

+import java.util.Locale;

 

 import org.apache.wicket.PageParameters;

+import org.apache.wicket.ajax.AjaxRequestTarget;

+import org.apache.wicket.ajax.markup.html.form.AjaxButton;

 import org.apache.wicket.markup.html.basic.Label;

-import org.apache.wicket.markup.html.link.BookmarkablePageLink;

+import org.apache.wicket.markup.html.form.Form;

+import org.apache.wicket.markup.html.panel.Fragment;

 import org.apache.wicket.markup.repeater.Item;

 import org.apache.wicket.markup.repeater.data.DataView;

 import org.apache.wicket.markup.repeater.data.ListDataProvider;

-import org.eclipse.jgit.lib.PersonIdent;

+import org.apache.wicket.model.IModel;

+import org.apache.wicket.model.Model;

 

+import com.gitblit.GitBlitException;

 import com.gitblit.Keys;

 import com.gitblit.models.Menu.ParameterMenuItem;

 import com.gitblit.models.NavLink;

@@ -40,9 +48,12 @@
 import com.gitblit.wicket.GitBlitWebSession;

 import com.gitblit.wicket.GitblitRedirectException;

 import com.gitblit.wicket.WicketUtils;

-import com.gitblit.wicket.panels.GravatarImage;

-import com.gitblit.wicket.panels.LinkPanel;

+import com.gitblit.wicket.panels.BooleanOption;

+import com.gitblit.wicket.panels.ChoiceOption;

 import com.gitblit.wicket.panels.ProjectRepositoryPanel;

+import com.gitblit.wicket.panels.SshKeysPanel;

+import com.gitblit.wicket.panels.TextOption;

+import com.gitblit.wicket.panels.UserTitlePanel;

 

 public class UserPage extends RootPage {

 

@@ -83,21 +94,30 @@
 			user = new UserModel(userName);

 		}

 

-		add(new Label("userDisplayName", user.getDisplayName()));

-		add(new Label("userUsername", user.username));

-		LinkPanel email = new LinkPanel("userEmail", null, user.emailAddress, "mailto:#");

-		email.setRenderBodyOnly(true);

-		add(email.setVisible(app().settings().getBoolean(Keys.web.showEmailAddresses, true) && !StringUtils.isEmpty(user.emailAddress)));

 

-		PersonIdent person = new PersonIdent(user.getDisplayName(), user.emailAddress == null ? user.getDisplayName() : user.emailAddress);

-		add(new GravatarImage("gravatar", person, 210));

+		add(new UserTitlePanel("userTitlePanel", user, user.username));

 

 		UserModel sessionUser = GitBlitWebSession.get().getUser();

-		if (sessionUser != null && user.canCreate() && sessionUser.equals(user)) {

-			// user can create personal repositories

-			add(new BookmarkablePageLink<Void>("newRepository", app().getNewRepositoryPage()));

+		boolean isMyProfile = sessionUser != null && sessionUser.equals(user);

+

+		if (isMyProfile) {

+			addPreferences(user);

+

+			if (app().gitblit().isServingSSH()) {

+				// show the SSH key management tab

+				addSshKeys(user);

+			} else {

+				// SSH daemon is disabled, hide keys tab

+				add(new Label("sshKeysLink").setVisible(false));

+				add(new Label("sshKeysTab").setVisible(false));

+			}

 		} else {

-			add(new Label("newRepository").setVisible(false));

+			// visiting user

+			add(new Label("preferencesLink").setVisible(false));

+			add(new Label("preferencesTab").setVisible(false));

+

+			add(new Label("sshKeysLink").setVisible(false));

+			add(new Label("sshKeysTab").setVisible(false));

 		}

 

 		List<RepositoryModel> repositories = getRepositories(params);

@@ -145,4 +165,146 @@
 

 		navLinks.add(menu);

 	}

+

+	private void addPreferences(UserModel user) {

+		// add preferences

+		Form<Void> prefs = new Form<Void>("prefsForm");

+

+		List<Language> languages = Arrays.asList(

+				new Language("English","en"),

+				new Language("Español", "es"),

+				new Language("Français", "fr"),

+				new Language("日本語", "ja"),

+				new Language("한국말", "ko"),

+				new Language("Nederlands", "nl"),

+				new Language("Norsk", "no"),

+				new Language("Język Polski", "pl"),

+				new Language("Português", "pt_BR"),

+				new Language("中文", "zh_CN"));

+

+		Locale locale = user.getPreferences().getLocale();

+		if (locale == null) {

+			// user has not specified language preference

+			// try server default preference

+			String lc = app().settings().getString(Keys.web.forceDefaultLocale, null);

+			if (StringUtils.isEmpty(lc)) {

+				// server default language is not configured

+				// try browser preference

+				Locale sessionLocale = GitBlitWebSession.get().getLocale();

+				if (sessionLocale != null) {

+					locale = sessionLocale;

+				}

+			} else {

+

+			}

+		}

+

+		Language preferredLanguage = null;

+		if (locale != null) {

+			String localeCode = locale.getLanguage();

+			if (!StringUtils.isEmpty(locale.getCountry())) {

+				localeCode += "_" + locale.getCountry();

+			}

+

+			for (Language language : languages) {

+				if (language.code.equals(localeCode)) {

+					// language_COUNTRY match

+					preferredLanguage = language;

+				} else if (preferredLanguage != null && language.code.startsWith(locale.getLanguage())) {

+					// language match

+					preferredLanguage = language;

+				}

+			}

+		}

+

+		final IModel<String> displayName = Model.of(user.getDisplayName());

+		final IModel<String> emailAddress = Model.of(user.emailAddress == null ? "" : user.emailAddress);

+		final IModel<Language> language = Model.of(preferredLanguage);

+		final IModel<Boolean> emailMeOnMyTicketChanges = Model.of(user.getPreferences().isEmailMeOnMyTicketChanges());

+

+		prefs.add(new TextOption("displayName",

+				getString("gb.displayName"),

+				getString("gb.displayNameDescription"),

+				displayName).setVisible(app().authentication().supportsDisplayNameChanges(user)));

+

+		prefs.add(new TextOption("emailAddress",

+				getString("gb.emailAddress"),

+				getString("gb.emailAddressDescription"),

+				emailAddress).setVisible(app().authentication().supportsEmailAddressChanges(user)));

+

+		prefs.add(new ChoiceOption<Language>("language",

+				getString("gb.languagePreference"),

+				getString("gb.languagePreferenceDescription"),

+				language,

+				languages));

+

+		prefs.add(new BooleanOption("emailMeOnMyTicketChanges",

+				getString("gb.emailMeOnMyTicketChanges"),

+				getString("gb.emailMeOnMyTicketChangesDescription"),

+				emailMeOnMyTicketChanges).setVisible(app().notifier().isSendingMail()));

+

+		prefs.add(new AjaxButton("save") {

+

+			private static final long serialVersionUID = 1L;

+

+			@Override

+			protected void onSubmit(AjaxRequestTarget target, Form<?> form) {

+

+				UserModel user = GitBlitWebSession.get().getUser();

+

+				user.displayName = displayName.getObject();

+				user.emailAddress = emailAddress.getObject();

+

+				Language lang = language.getObject();

+				if (lang != null) {

+					user.getPreferences().setLocale(lang.code);

+				}

+

+				user.getPreferences().setEmailMeOnMyTicketChanges(emailMeOnMyTicketChanges.getObject());

+

+				try {

+					app().gitblit().reviseUser(user.username, user);

+

+					setRedirect(true);

+					setResponsePage(UserPage.class, WicketUtils.newUsernameParameter(user.username));

+				} catch (GitBlitException e) {

+					// logger.error("Failed to update user " + user.username, e);

+					// error(getString("gb.failedToUpdateUser"), false);

+				}

+			}

+		});

+

+		// add the preferences tab

+		add(new Fragment("preferencesLink", "preferencesLinkFragment", this).setRenderBodyOnly(true));

+		Fragment fragment = new Fragment("preferencesTab", "preferencesTabFragment", this);

+		fragment.add(prefs);

+		add(fragment.setRenderBodyOnly(true));

+	}

+

+	private void addSshKeys(final UserModel user) {

+		Fragment keysTab = new Fragment("sshKeysTab", "sshKeysTabFragment", this);

+		keysTab.add(new SshKeysPanel("sshKeysPanel", user));

+

+		// add the SSH keys tab

+		add(new Fragment("sshKeysLink", "sshKeysLinkFragment", this).setRenderBodyOnly(true));

+		add(keysTab.setRenderBodyOnly(true));

+	}

+

+	private class Language implements Serializable {

+

+		private static final long serialVersionUID = 1L;

+

+		final String name;

+		final String code;

+

+		public Language(String name, String code) {

+			this.name = name;

+			this.code = code;

+		}

+

+		@Override

+		public String toString() {

+			return name + " (" + code +")";

+		}

+	}

 }

diff --git a/src/main/java/com/gitblit/wicket/panels/AccessPolicyPanel.html b/src/main/java/com/gitblit/wicket/panels/AccessPolicyPanel.html
index 87a0206..0705019 100644
--- a/src/main/java/com/gitblit/wicket/panels/AccessPolicyPanel.html
+++ b/src/main/java/com/gitblit/wicket/panels/AccessPolicyPanel.html
@@ -13,7 +13,7 @@
 	<div wicket:id="policiesGroup">

 		<div wicket:id="policies" style="padding-top:4px;">

 			<div>

-				<label style="font-weight:bold;"><input type="radio" wicket:id="radio" /> <img wicket:id="image"></img> <span wicket:id="name"></span></label>

+				<label style="font-weight:bold;margin-bottom:1px;"><input type="radio" wicket:id="radio" /> <img wicket:id="image"></img> <span wicket:id="name"></span></label>

 			</div>

 			<label class="checkbox" style="color:#777;" wicket:id="description"></label>

 		</div>

diff --git a/src/main/java/com/gitblit/wicket/panels/BooleanChoiceOption.html b/src/main/java/com/gitblit/wicket/panels/BooleanChoiceOption.html
index fb360d1..b1ced8d 100644
--- a/src/main/java/com/gitblit/wicket/panels/BooleanChoiceOption.html
+++ b/src/main/java/com/gitblit/wicket/panels/BooleanChoiceOption.html
@@ -8,7 +8,7 @@
 <wicket:panel>

 	<div style="padding-top:4px;">

 		<div>

-			<label style="font-weight:bold;" class="checkbox"><input type="checkbox" wicket:id="checkbox" /> <span wicket:id="name"></span></label>

+			<label style="font-weight:bold;margin-bottom:1px;" class="checkbox"><input type="checkbox" wicket:id="checkbox" /> <span wicket:id="name"></span></label>

 		</div>

 		<label class="checkbox" style="color:#777;"> <span wicket:id="description"></span>

 		<p style="padding-top:5px;"><select class="span3" wicket:id="choice" /></p>

diff --git a/src/main/java/com/gitblit/wicket/panels/BooleanOption.html b/src/main/java/com/gitblit/wicket/panels/BooleanOption.html
index 6684fe9..297cbd3 100644
--- a/src/main/java/com/gitblit/wicket/panels/BooleanOption.html
+++ b/src/main/java/com/gitblit/wicket/panels/BooleanOption.html
@@ -8,7 +8,7 @@
 <wicket:panel>

 	<div style="padding-top:4px;">

 		<div>

-			<label style="font-weight:bold;" class="checkbox"><input type="checkbox" wicket:id="checkbox" /> <span wicket:id="name"></span></label>

+			<label style="font-weight:bold;margin-bottom:1px;" class="checkbox"><input type="checkbox" wicket:id="checkbox" /> <span wicket:id="name"></span></label>

 		</div>

 		<label class="checkbox" style="color:#777;" wicket:id="description"></label>

 	</div>

diff --git a/src/main/java/com/gitblit/wicket/panels/ChoiceOption.html b/src/main/java/com/gitblit/wicket/panels/ChoiceOption.html
index 8c34c81..e9e4887 100644
--- a/src/main/java/com/gitblit/wicket/panels/ChoiceOption.html
+++ b/src/main/java/com/gitblit/wicket/panels/ChoiceOption.html
@@ -7,7 +7,7 @@
 <body>

 <wicket:panel>

 	<div style="padding-top:4px;">

-		<div>

+		<div style="margin-bottom:1px;">

 			<b><span wicket:id="name"></span></b>

 		</div>

 		<label class="checkbox" style="color:#777;"> <span wicket:id="description"></span>

diff --git a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.html b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.html
new file mode 100644
index 0000000..d67b704
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

+<html xmlns="http://www.w3.org/1999/xhtml"  

+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  

+      xml:lang="en"  

+      lang="en"> 

+

+<body>

+<wicket:panel>

+	<h4><wicket:message key="gb.sshKeys"></wicket:message></h4>

+	<p><wicket:message key="gb.sshKeysDescription"></wicket:message></p>

+	<hr />

+		

+	<div wicket:id="keys">

+		<div style="display:inline-block;font-size:2em;padding:10px;">

+			<i class="fa fa-key"></i>

+		</div>

+		<div style="display:inline-block;">

+			<div wicket:id="comment" style="font-weight:bold;"></div>

+			<pre wicket:id="fingerprint"></pre>

+		</div>

+		

+		<div style="display:inline-block;padding: 0px 20px">

+			<div wicket:id="permission" style="font-weight:bold;"></div>

+			<div wicket:id="algorithm"></div>

+		</div>

+		

+		<div style="display:inline-block;vertical-align:text-bottom;">

+			<button class="btn btn-danger" wicket:id="delete"><wicket:message key="gb.delete"></wicket:message></button>

+		</div>

+		

+		<hr />

+	</div>

+	

+	<div class="well">

+		<form wicket:id="addKeyForm">

+			<h4><wicket:message key="gb.addSshKey"></wicket:message></h4>

+			<div wicket:id="addKeyData"></div>

+			<div wicket:id="addKeyPermission"></div>

+			<div wicket:id="addKeyComment"></div>

+		

+			<div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Add" wicket:message="value:gb.add" wicket:id="addKeyButton" /></div>

+		</form>

+	</div>

+</wicket:panel>

+</body>

+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java
new file mode 100644
index 0000000..15ebd67
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java
@@ -0,0 +1,169 @@
+/*

+ * Copyright 2014 gitblit.com.

+ *

+ * 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.gitblit.wicket.panels;

+

+import java.util.ArrayList;

+import java.util.Arrays;

+import java.util.List;

+

+import org.apache.wicket.ajax.AjaxRequestTarget;

+import org.apache.wicket.ajax.markup.html.AjaxLink;

+import org.apache.wicket.ajax.markup.html.form.AjaxButton;

+import org.apache.wicket.markup.html.basic.Label;

+import org.apache.wicket.markup.html.form.Form;

+import org.apache.wicket.markup.repeater.Item;

+import org.apache.wicket.markup.repeater.data.DataView;

+import org.apache.wicket.markup.repeater.data.ListDataProvider;

+import org.apache.wicket.model.IModel;

+import org.apache.wicket.model.Model;

+

+import com.gitblit.Constants.AccessPermission;

+import com.gitblit.models.UserModel;

+import com.gitblit.transport.ssh.SshKey;

+import com.gitblit.utils.StringUtils;

+import com.gitblit.wicket.GitBlitWebSession;

+

+

+/**

+ * A panel that enumerates and manages SSH public keys using AJAX.

+ *

+ * @author James Moger

+ *

+ */

+public class SshKeysPanel extends BasePanel {

+

+	private static final long serialVersionUID = 1L;

+

+	private final UserModel user;

+

+	public SshKeysPanel(String wicketId, UserModel user) {

+		super(wicketId);

+

+		this.user = user;

+	}

+

+	@Override

+	protected void onInitialize() {

+		super.onInitialize();

+

+		setOutputMarkupId(true);

+

+		final List<SshKey> keys = new ArrayList<SshKey>(app().keys().getKeys(user.username));

+		final ListDataProvider<SshKey> dp = new ListDataProvider<SshKey>(keys);

+		final DataView<SshKey> keysView = new DataView<SshKey>("keys", dp) {

+			private static final long serialVersionUID = 1L;

+

+			@Override

+			public void populateItem(final Item<SshKey> item) {

+				final SshKey key = item.getModelObject();

+				item.add(new Label("comment", key.getComment()));

+				item.add(new Label("fingerprint", key.getFingerprint()));

+				item.add(new Label("permission", key.getPermission().toString()));

+				item.add(new Label("algorithm", key.getAlgorithm()));

+

+				AjaxLink<Void> delete = new AjaxLink<Void>("delete") {

+

+					private static final long serialVersionUID = 1L;

+

+					@Override

+					public void onClick(AjaxRequestTarget target) {

+						if (app().keys().removeKey(user.username, key)) {

+							// reset the keys list

+							keys.clear();

+							keys.addAll(app().keys().getKeys(user.username));

+

+							// update the panel

+							target.addComponent(SshKeysPanel.this);

+						}

+					}

+				};

+				item.add(delete);

+			}

+		};

+		add(keysView);

+

+		Form<Void> addKeyForm = new Form<Void>("addKeyForm");

+

+		final IModel<String> keyData = Model.of("");

+		addKeyForm.add(new TextAreaOption("addKeyData",

+				getString("gb.key"),

+				null,

+				"span5",

+				keyData));

+

+		final IModel<AccessPermission> keyPermission = Model.of(AccessPermission.PUSH);

+		addKeyForm.add(new ChoiceOption<AccessPermission>("addKeyPermission",

+				getString("gb.permission"),

+				getString("gb.sshKeyPermissionDescription"),

+				keyPermission,

+				Arrays.asList(AccessPermission.SSHPERMISSIONS)));

+

+		final IModel<String> keyComment = Model.of("");

+		addKeyForm.add(new TextOption("addKeyComment",

+				getString("gb.comment"),

+				getString("gb.sshKeyCommentDescription"),

+				"span5",

+				keyComment));

+

+		addKeyForm.add(new AjaxButton("addKeyButton") {

+

+			private static final long serialVersionUID = 1L;

+

+			@Override

+			protected void onSubmit(AjaxRequestTarget target, Form<?> form) {

+

+				UserModel user = GitBlitWebSession.get().getUser();

+				String data = keyData.getObject();

+				if (StringUtils.isEmpty(data)) {

+					// do not submit empty key

+					return;

+				}

+

+				SshKey key = new SshKey(data);

+				try {

+					key.getPublicKey();

+				} catch (Exception e) {

+					// failed to parse the key

+					return;

+				}

+

+				AccessPermission permission = keyPermission.getObject();

+				key.setPermission(permission);

+

+				String comment  = keyComment.getObject();

+				if (!StringUtils.isEmpty(comment)) {

+					key.setComment(comment);

+				}

+

+				if (app().keys().addKey(user.username, key)) {

+					// reset add key fields

+					keyData.setObject("");

+					keyPermission.setObject(AccessPermission.PUSH);

+					keyComment.setObject("");

+

+					// reset the keys list

+					keys.clear();

+					keys.addAll(app().keys().getKeys(user.username));

+

+					// update the panel

+					target.addComponent(SshKeysPanel.this);

+				}

+			}

+		});

+

+		add(addKeyForm);

+	}

+}

diff --git a/src/main/java/com/gitblit/wicket/panels/TextAreaOption.html b/src/main/java/com/gitblit/wicket/panels/TextAreaOption.html
new file mode 100644
index 0000000..bb7dc7c
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/panels/TextAreaOption.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

+<html xmlns="http://www.w3.org/1999/xhtml"  

+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  

+      xml:lang="en"  

+      lang="en"> 

+

+<body>

+<wicket:panel>

+	<div style="padding-top:4px;">

+		<div style="margin-bottom:1px;">

+			<b><span wicket:id="name"></span></b>

+		</div>

+		<label class="checkbox" style="color:#777;"> <span wicket:id="description"></span>

+		<p style="padding-top:5px;"><textarea rows="12" class="span5" wicket:id="text"></textarea></p>

+		</label>

+		

+	</div>

+</wicket:panel>

+</body>

+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/panels/TextAreaOption.java b/src/main/java/com/gitblit/wicket/panels/TextAreaOption.java
new file mode 100644
index 0000000..d2c74a0
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/panels/TextAreaOption.java
@@ -0,0 +1,54 @@
+/*

+ * Copyright 2014 gitblit.com.

+ *

+ * 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.gitblit.wicket.panels;

+

+import org.apache.wicket.markup.html.basic.Label;

+import org.apache.wicket.markup.html.form.TextArea;

+import org.apache.wicket.model.IModel;

+

+import com.gitblit.utils.StringUtils;

+import com.gitblit.wicket.WicketUtils;

+

+/**

+ * A re-usable textarea option panel.

+ *

+ * title

+ *     description

+ *     [text

+ *           area]

+ *

+ * @author James Moger

+ *

+ */

+public class TextAreaOption extends BasePanel {

+

+	private static final long serialVersionUID = 1L;

+

+	public TextAreaOption(String wicketId, String title, String description, IModel<String> model) {

+		this(wicketId, title, description, null, model);

+	}

+

+	public TextAreaOption(String wicketId, String title, String description, String css, IModel<String> model) {

+		super(wicketId);

+		add(new Label("name", title));

+		add(new Label("description", description).setVisible(!StringUtils.isEmpty(description)));

+		TextArea<String> tf = new TextArea<String>("text", model);

+		if (!StringUtils.isEmpty(css)) {

+			WicketUtils.setCssClass(tf, css);

+		}

+		add(tf);

+	}

+}

diff --git a/src/main/java/com/gitblit/wicket/panels/TextOption.html b/src/main/java/com/gitblit/wicket/panels/TextOption.html
index 9fa0c70..d14da2b 100644
--- a/src/main/java/com/gitblit/wicket/panels/TextOption.html
+++ b/src/main/java/com/gitblit/wicket/panels/TextOption.html
@@ -7,7 +7,7 @@
 <body>

 <wicket:panel>

 	<div style="padding-top:4px;">

-		<div>

+		<div style="margin-bottom:1px;">

 			<b><span wicket:id="name"></span></b>

 		</div>

 		<label class="checkbox" style="color:#777;"> <span wicket:id="description"></span>

diff --git a/src/test/java/com/gitblit/tests/mock/MockRuntimeManager.java b/src/test/java/com/gitblit/tests/mock/MockRuntimeManager.java
index 6e56a87..54be539 100644
--- a/src/test/java/com/gitblit/tests/mock/MockRuntimeManager.java
+++ b/src/test/java/com/gitblit/tests/mock/MockRuntimeManager.java
@@ -82,6 +82,21 @@
 	}
 
 	@Override
+	public boolean isServingHTTP() {
+		return true;
+	}
+
+	@Override
+	public boolean isServingGIT() {
+		return true;
+	}
+
+	@Override
+	public boolean isServingSSH() {
+		return true;
+	}
+
+	@Override
 	public boolean isDebugMode() {
 		return true;
 	}