From fe24a0be919653d9e502f7729d9a804f2e28435d Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Wed, 07 Dec 2011 19:33:10 -0500
Subject: [PATCH] Teams support.

---
 tests/com/gitblit/tests/UserServiceTest.java         |  107 +++
 src/com/gitblit/ConfigUserService.java               |  307 +++++++++
 src/com/gitblit/IUserService.java                    |   79 ++
 src/com/gitblit/models/UserModel.java                |   25 
 src/com/gitblit/wicket/pages/EditUserPage.html       |    5 
 src/com/gitblit/GitBlit.java                         |   81 ++
 src/com/gitblit/wicket/GitBlitWebApp.properties      |    8 
 src/com/gitblit/wicket/WicketUtils.java              |    8 
 docs/04_releases.mkd                                 |    1 
 src/com/gitblit/wicket/panels/UsersPanel.html        |    2 
 src/com/gitblit/wicket/pages/EditRepositoryPage.html |    3 
 docs/01_features.mkd                                 |    1 
 docs/01_setup.mkd                                    |  179 +-----
 src/com/gitblit/wicket/pages/EditRepositoryPage.java |   19 
 src/com/gitblit/wicket/pages/UsersPage.html          |    2 
 src/com/gitblit/wicket/pages/EditUserPage.java       |   26 
 src/com/gitblit/wicket/panels/TeamsPanel.html        |   44 +
 src/com/gitblit/wicket/pages/EditTeamPage.html       |   24 
 src/com/gitblit/FileUserService.java                 |  323 ++++++++++
 src/com/gitblit/utils/DeepCopier.java                |  135 ++++
 src/com/gitblit/wicket/pages/EditTeamPage.java       |  169 +++++
 resources/users_16x16.png                            |    0 
 src/com/gitblit/models/TeamModel.java                |   88 +++
 src/com/gitblit/wicket/panels/TeamsPanel.java        |   89 +++
 src/com/gitblit/wicket/pages/UsersPage.java          |    3 
 25 files changed, 1,547 insertions(+), 181 deletions(-)

diff --git a/docs/01_features.mkd b/docs/01_features.mkd
index b43ea46..4172f4e 100644
--- a/docs/01_features.mkd
+++ b/docs/01_features.mkd
@@ -13,6 +13,7 @@
 - Gitweb inspired web UI
 - Administrators may create, edit, rename, or delete repositories through the web UI or RPC interface
 - Administrators may create, edit, rename, or delete users through the web UI or RPC interface
+- Administrators may create, edit, rename, or delete teams through the web UI or RPC interface
 - Repository Owners may edit repositories through the web UI
 - Git-notes display support
 - Branch metrics (uses Google Charts)
diff --git a/docs/01_setup.mkd b/docs/01_setup.mkd
index 468421d..3256d1b 100644
--- a/docs/01_setup.mkd
+++ b/docs/01_setup.mkd
@@ -157,8 +157,13 @@
 #### Repository Owner
 The *Repository Owner* has the special permission of being able to edit a repository through the web UI.  The Repository Owner is not permitted to rename the repository, delete the repository, or reassign ownership to another user.
 
-### Administering Users (Gitblit v0.8.0+)
-All users are stored in the `users.conf` file or in the file you specified in `gitblit.properties`.<br/>
+### Teams
+
+Since v0.8.0, Gitblit supports *teams* for the original `users.properties` user service and the current default user service `users.conf`.  Teams have assigned users and assigned repositories.  A user can be a member of multiple teams and a repository may belong to multiple teams.  This allows the administrator to quickly add a user to a team without having to keep track of all the appropriate repositories. 
+
+### Administering Users (users.conf, Gitblit v0.8.0+)
+All users are stored in the `users.conf` file or in the file you specified in `gitblit.properties`. Your file extension must be *.conf* in order to use this user service.
+
 The `users.conf` file uses a Git-style configuration format:
 
     [user "admin"]
@@ -167,14 +172,35 @@
 	    role = "#notfederated"
 	    repository = repo1.git
 	    repository = repo2.git
+	    
+	[user "hannibal"]
+		password = bossman
+
+	[user "faceman"]
+		password = vanity
+
+	[user "murdock"]
+		password = crazy		
+	    
+	[user "babaracus"]
+		password = grrrr
+	    
+	[team "ateam"]
+		user = hannibal
+		user = faceman
+		user = murdock
+		user = babaracus
+		repository = topsecret.git
 
 The `users.conf` file allows flexibility for adding new fields to a UserModel object that the original `users.properties` file does not afford without imposing the complexity of relying on an embedded SQL database. 
 
-### Administering Users (Gitblit v0.5.0 - v0.7.0)
-All users are stored in the `users.properties` file or in the file you specified in `gitblit.properties`.<br/>
+### Administering Users (users.properties, Gitblit v0.5.0 - v0.7.0)
+All users are stored in the `users.properties` file or in the file you specified in `gitblit.properties`. Your file extension must be *.properties* in order to use this user service.
+
 The format of `users.properties` follows Jetty's convention for HashRealms:
 
     username,password,role1,role2,role3...
+    @teamname,!username1,!username2,!username3,repository1,repository2,repository3...
 
 #### Usernames
 Usernames must be unique and are case-insensitive.  
@@ -191,149 +217,8 @@
 
 You may use your own custom *com.gitblit.IUserService* implementation by specifying its fully qualified classname in the *realm.userService* setting.
 
-Your user service class must be on Gitblit's classpath and must have a public default constructor. 
-
-%BEGINCODE%
-public interface IUserService {
-
-	/**
-	 * Setup the user service.
-	 * 
-	 * @param settings
-	 * @since 0.7.0
-	 */
-	@Override
-	public void setup(IStoredSettings settings) {
-	}
-	
-	/**
-	 * Does the user service support cookie authentication?
-	 * 
-	 * @return true or false
-	 */
-	boolean supportsCookies();
-
-	/**
-	 * Returns the cookie value for the specified user.
-	 * 
-	 * @param model
-	 * @return cookie value
-	 */
-	char[] getCookie(UserModel model);
-
-	/**
-	 * Authenticate a user based on their cookie.
-	 * 
-	 * @param cookie
-	 * @return a user object or null
-	 */
-	UserModel authenticate(char[] cookie);
-
-	/**
-	 * Authenticate a user based on a username and password.
-	 * 
-	 * @param username
-	 * @param password
-	 * @return a user object or null
-	 */
-	UserModel authenticate(String username, char[] password);
-
-	/**
-	 * Retrieve the user object for the specified username.
-	 * 
-	 * @param username
-	 * @return a user object or null
-	 */
-	UserModel getUserModel(String username);
-
-	/**
-	 * Updates/writes a complete user object.
-	 * 
-	 * @param model
-	 * @return true if update is successful
-	 */
-	boolean updateUserModel(UserModel model);
-
-	/**
-	 * Adds/updates a user object keyed by username. This method allows for
-	 * renaming a user.
-	 * 
-	 * @param username
-	 *            the old username
-	 * @param model
-	 *            the user object to use for username
-	 * @return true if update is successful
-	 */
-	boolean updateUserModel(String username, UserModel model);
-
-	/**
-	 * Deletes the user object from the user service.
-	 * 
-	 * @param model
-	 * @return true if successful
-	 */
-	boolean deleteUserModel(UserModel model);
-
-	/**
-	 * Delete the user object with the specified username
-	 * 
-	 * @param username
-	 * @return true if successful
-	 */
-	boolean deleteUser(String username);
-
-	/**
-	 * Returns the list of all users available to the login service.
-	 * 
-	 * @return list of all usernames
-	 */
-	List<String> getAllUsernames();
-
-	/**
-	 * Returns the list of all users who are allowed to bypass the access
-	 * restriction placed on the specified repository.
-	 * 
-	 * @param role
-	 *            the repository name
-	 * @return list of all usernames that can bypass the access restriction
-	 */
-	List<String> getUsernamesForRepositoryRole(String role);
-
-	/**
-	 * Sets the list of all uses who are allowed to bypass the access
-	 * restriction placed on the specified repository.
-	 * 
-	 * @param role
-	 *            the repository name
-	 * @param usernames
-	 * @return true if successful
-	 */
-	boolean setUsernamesForRepositoryRole(String role, List<String> usernames);
-
-	/**
-	 * Renames a repository role.
-	 * 
-	 * @param oldRole
-	 * @param newRole
-	 * @return true if successful
-	 */
-	boolean renameRepositoryRole(String oldRole, String newRole);
-
-	/**
-	 * Removes a repository role from all users.
-	 * 
-	 * @param role
-	 * @return true if successful
-	 */
-	boolean deleteRepositoryRole(String role);
-
-	/**
-	 * @See java.lang.Object.toString();
-	 * @return string representation of the login service
-	 */
-	String toString();
-}
-%ENDCODE%
+Your user service class must be on Gitblit's classpath and must have a public default constructor.  
+Please see the following interface definition [com.gitblit.IUserService](https://github.com/gitblit/gitblit/blob/master/src/com/gitblit/IUserService.java).
 
 ## Client Setup and Configuration
 ### Https with Self-Signed Certificates
diff --git a/docs/04_releases.mkd b/docs/04_releases.mkd
index 7a0cb09..343f794 100644
--- a/docs/04_releases.mkd
+++ b/docs/04_releases.mkd
@@ -6,6 +6,7 @@
 - added: new default user service implementation: com.gitblit.ConfigUserService (users.conf)  
 This user service implementation allows for serialization and deserialization of more sophisticated Gitblit User objects and will open the door for more advanced Gitblit features. For upgrading installations, a `users.conf` file will automatically be created for you from your existing `users.properties` file on your first launch of Gitblit.  You will have to manually set *realm.userService=users.conf* to switch to the new user service.  The original `users.properties` file and it's corresponding implementation are deprecated.  
     **New:** *realm.userService = users.conf*
+- added: Teams for specifying user-repository access
 - added: Gitblit Express bundle to get started running Gitblit on RedHat's OpenShift cloud
 - added: optional Gravatar integration  
     **New:** *web.allowGravatar = true*   
diff --git a/resources/users_16x16.png b/resources/users_16x16.png
new file mode 100644
index 0000000..247af64
--- /dev/null
+++ b/resources/users_16x16.png
Binary files differ
diff --git a/src/com/gitblit/ConfigUserService.java b/src/com/gitblit/ConfigUserService.java
index 28a16c5..a0a38e6 100644
--- a/src/com/gitblit/ConfigUserService.java
+++ b/src/com/gitblit/ConfigUserService.java
@@ -32,7 +32,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
+import com.gitblit.utils.DeepCopier;
 import com.gitblit.utils.StringUtils;
 
 /**
@@ -51,6 +53,16 @@
  */
 public class ConfigUserService implements IUserService {
 
+	private static final String TEAM = "team";
+
+	private static final String USER = "user";
+
+	private static final String PASSWORD = "password";
+
+	private static final String REPOSITORY = "repository";
+
+	private static final String ROLE = "role";
+
 	private final File realmFile;
 
 	private final Logger logger = LoggerFactory.getLogger(ConfigUserService.class);
@@ -59,13 +71,7 @@
 
 	private final Map<String, UserModel> cookies = new ConcurrentHashMap<String, UserModel>();
 
-	private final String userSection = "user";
-
-	private final String passwordField = "password";
-
-	private final String repositoryField = "repository";
-
-	private final String roleField = "role";
+	private final Map<String, TeamModel> teams = new ConcurrentHashMap<String, TeamModel>();
 
 	private volatile long lastModified;
 
@@ -77,7 +83,7 @@
 	 * Setup the user service.
 	 * 
 	 * @param settings
-	 * @since 0.6.1
+	 * @since 0.7.0
 	 */
 	@Override
 	public void setup(IStoredSettings settings) {
@@ -172,6 +178,11 @@
 	public UserModel getUserModel(String username) {
 		read();
 		UserModel model = users.get(username.toLowerCase());
+		if (model != null) {
+			// clone the model, otherwise all changes to this object are
+			// live and unpersisted
+			model = DeepCopier.copy(model);
+		}
 		return model;
 	}
 
@@ -200,8 +211,34 @@
 	public boolean updateUserModel(String username, UserModel model) {
 		try {
 			read();
-			users.remove(username.toLowerCase());
+			UserModel oldUser = users.remove(username.toLowerCase());
 			users.put(model.username.toLowerCase(), model);
+			// null check on "final" teams because JSON-sourced UserModel
+			// can have a null teams object
+			if (model.teams != null) {
+				for (TeamModel team : model.teams) {
+					TeamModel t = teams.get(team.name.toLowerCase());
+					if (t == null) {
+						// new team
+						team.addUser(username);
+						teams.put(team.name.toLowerCase(), team);
+					} else {
+						// do not clobber existing team definition
+						// maybe because this is a federated user
+						t.removeUser(username);
+						t.addUser(model.username);
+					}
+				}
+
+				// check for implicit team removal
+				if (oldUser != null) {
+					for (TeamModel team : oldUser.teams) {
+						if (!model.isTeamMember(team.name)) {
+							team.removeUser(username);
+						}
+					}
+				}
+			}
 			write();
 			return true;
 		} catch (Throwable t) {
@@ -233,11 +270,189 @@
 		try {
 			// Read realm file
 			read();
-			users.remove(username.toLowerCase());
+			UserModel model = users.remove(username.toLowerCase());
+			// remove user from team
+			for (TeamModel team : model.teams) {
+				TeamModel t = teams.get(team.name);
+				if (t == null) {
+					// new team
+					team.removeUser(username);
+					teams.put(team.name.toLowerCase(), team);
+				} else {
+					// existing team
+					t.removeUser(username);
+				}
+			}
 			write();
 			return true;
 		} catch (Throwable t) {
 			logger.error(MessageFormat.format("Failed to delete user {0}!", username), t);
+		}
+		return false;
+	}
+
+	/**
+	 * Returns the list of all teams available to the login service.
+	 * 
+	 * @return list of all teams
+	 * @since 0.8.0
+	 */
+	@Override
+	public List<String> getAllTeamNames() {
+		read();
+		List<String> list = new ArrayList<String>(teams.keySet());
+		return list;
+	}
+	
+	/**
+	 * Returns the list of all users who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @return list of all usernames that can bypass the access restriction
+	 */
+	@Override
+	public List<String> getTeamnamesForRepositoryRole(String role) {
+		List<String> list = new ArrayList<String>();
+		try {
+			read();
+			for (Map.Entry<String, TeamModel> entry : teams.entrySet()) {
+				TeamModel model = entry.getValue();
+				if (model.hasRepository(role)) {
+					list.add(model.name);
+				}
+			}
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to get teamnames for role {0}!", role), t);
+		}
+		return list;
+	}
+
+	/**
+	 * Sets the list of all teams who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @param teamnames
+	 * @return true if successful
+	 */
+	@Override
+	public boolean setTeamnamesForRepositoryRole(String role, List<String> teamnames) {
+		try {
+			Set<String> specifiedTeams = new HashSet<String>();
+			for (String teamname : teamnames) {
+				specifiedTeams.add(teamname.toLowerCase());
+			}
+
+			read();
+
+			// identify teams which require add or remove role
+			for (TeamModel team : teams.values()) {
+				// team has role, check against revised team list
+				if (specifiedTeams.contains(team.name.toLowerCase())) {
+					team.addRepository(role);
+				} else {
+					// remove role from team
+					team.removeRepository(role);
+				}
+			}
+
+			// persist changes
+			write();
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to set teams for role {0}!", role), t);
+		}
+		return false;
+	}
+
+	/**
+	 * Retrieve the team object for the specified team name.
+	 * 
+	 * @param teamname
+	 * @return a team object or null
+	 * @since 0.8.0
+	 */
+	@Override
+	public TeamModel getTeamModel(String teamname) {
+		read();
+		TeamModel model = teams.get(teamname.toLowerCase());
+		if (model != null) {
+			// clone the model, otherwise all changes to this object are
+			// live and unpersisted
+			model = DeepCopier.copy(model);
+		}
+		return model;
+	}
+
+	/**
+	 * Updates/writes a complete team object.
+	 * 
+	 * @param model
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean updateTeamModel(TeamModel model) {
+		return updateTeamModel(model.name, model);
+	}
+
+	/**
+	 * Updates/writes and replaces a complete team object keyed by teamname.
+	 * This method allows for renaming a team.
+	 * 
+	 * @param teamname
+	 *            the old teamname
+	 * @param model
+	 *            the team object to use for teamname
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean updateTeamModel(String teamname, TeamModel model) {
+		try {
+			read();
+			teams.remove(teamname.toLowerCase());
+			teams.put(model.name.toLowerCase(), model);
+			write();
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to update team model {0}!", model.name), t);
+		}
+		return false;
+	}
+
+	/**
+	 * Deletes the team object from the user service.
+	 * 
+	 * @param model
+	 * @return true if successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean deleteTeamModel(TeamModel model) {
+		return deleteTeam(model.name);
+	}
+
+	/**
+	 * Delete the team object with the specified teamname
+	 * 
+	 * @param teamname
+	 * @return true if successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean deleteTeam(String teamname) {
+		try {
+			// Read realm file
+			read();
+			teams.remove(teamname.toLowerCase());
+			write();
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to delete team {0}!", teamname), t);
 		}
 		return false;
 	}
@@ -337,6 +552,13 @@
 				}
 			}
 
+			// identify teams which require role rename
+			for (TeamModel model : teams.values()) {
+				if (model.hasRepository(oldRole)) {
+					model.removeRepository(oldRole);
+					model.addRepository(newRole);
+				}
+			}
 			// persist changes
 			write();
 			return true;
@@ -363,6 +585,11 @@
 				user.removeRepository(role);
 			}
 
+			// identify teams which require role rename
+			for (TeamModel team : teams.values()) {
+				team.removeRepository(role);
+			}
+
 			// persist changes
 			write();
 			return true;
@@ -383,8 +610,10 @@
 		File realmFileCopy = new File(realmFile.getAbsolutePath() + ".tmp");
 
 		StoredConfig config = new FileBasedConfig(realmFileCopy, FS.detect());
+
+		// write users
 		for (UserModel model : users.values()) {
-			config.setString(userSection, model.username, passwordField, model.password);
+			config.setString(USER, model.username, PASSWORD, model.password);
 
 			// user roles
 			List<String> roles = new ArrayList<String>();
@@ -394,12 +623,33 @@
 			if (model.excludeFromFederation) {
 				roles.add(Constants.NOT_FEDERATED_ROLE);
 			}
-			config.setStringList(userSection, model.username, roleField, roles);
+			config.setStringList(USER, model.username, ROLE, roles);
 
 			// repository memberships
-			config.setStringList(userSection, model.username, repositoryField,
-					new ArrayList<String>(model.repositories));
+			// null check on "final" repositories because JSON-sourced UserModel
+			// can have a null repositories object
+			if (model.repositories != null) {
+				config.setStringList(USER, model.username, REPOSITORY, new ArrayList<String>(
+						model.repositories));
+			}
 		}
+
+		// write teams
+		for (TeamModel model : teams.values()) {
+			// null check on "final" repositories because JSON-sourced TeamModel
+			// can have a null repositories object
+			if (model.repositories != null) {
+				config.setStringList(TEAM, model.name, REPOSITORY, new ArrayList<String>(
+						model.repositories));
+			}
+
+			// null check on "final" users because JSON-sourced TeamModel
+			// can have a null users object
+			if (model.users != null) {
+				config.setStringList(TEAM, model.name, USER, new ArrayList<String>(model.users));
+			}
+		}
+
 		config.save();
 
 		// If the write is successful, delete the current file and rename
@@ -429,23 +679,25 @@
 			lastModified = realmFile.lastModified();
 			users.clear();
 			cookies.clear();
+			teams.clear();
+
 			try {
 				StoredConfig config = new FileBasedConfig(realmFile, FS.detect());
 				config.load();
-				Set<String> usernames = config.getSubsections(userSection);
+				Set<String> usernames = config.getSubsections(USER);
 				for (String username : usernames) {
 					UserModel user = new UserModel(username);
-					user.password = config.getString(userSection, username, passwordField);
+					user.password = config.getString(USER, username, PASSWORD);
 
 					// user roles
 					Set<String> roles = new HashSet<String>(Arrays.asList(config.getStringList(
-							userSection, username, roleField)));
+							USER, username, ROLE)));
 					user.canAdmin = roles.contains(Constants.ADMIN_ROLE);
 					user.excludeFromFederation = roles.contains(Constants.NOT_FEDERATED_ROLE);
 
 					// repository memberships
 					Set<String> repositories = new HashSet<String>(Arrays.asList(config
-							.getStringList(userSection, username, repositoryField)));
+							.getStringList(USER, username, REPOSITORY)));
 					for (String repository : repositories) {
 						user.addRepository(repository);
 					}
@@ -454,6 +706,25 @@
 					users.put(username, user);
 					cookies.put(StringUtils.getSHA1(username + user.password), user);
 				}
+
+				// load the teams
+				Set<String> teamnames = config.getSubsections(TEAM);
+				for (String teamname : teamnames) {
+					TeamModel team = new TeamModel(teamname);
+					team.addRepositories(Arrays.asList(config.getStringList(TEAM, teamname,
+							REPOSITORY)));
+					team.addUsers(Arrays.asList(config.getStringList(TEAM, teamname, USER)));
+
+					teams.put(team.name.toLowerCase(), team);
+
+					// set the teams on the users
+					for (String user : team.users) {
+						UserModel model = users.get(user);
+						if (model != null) {
+							model.teams.add(team);
+						}
+					}
+				}
 			} catch (Exception e) {
 				logger.error(MessageFormat.format("Failed to read {0}", realmFile), e);
 			}
diff --git a/src/com/gitblit/FileUserService.java b/src/com/gitblit/FileUserService.java
index a98e417..880ca7b 100644
--- a/src/com/gitblit/FileUserService.java
+++ b/src/com/gitblit/FileUserService.java
@@ -30,7 +30,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
+import com.gitblit.utils.DeepCopier;
 import com.gitblit.utils.StringUtils;
 
 /**
@@ -53,6 +55,8 @@
 
 	private final Map<String, String> cookies = new ConcurrentHashMap<String, String>();
 
+	private final Map<String, TeamModel> teams = new ConcurrentHashMap<String, TeamModel>();
+
 	public FileUserService(File realmFile) {
 		super(realmFile.getAbsolutePath());
 	}
@@ -61,7 +65,7 @@
 	 * Setup the user service.
 	 * 
 	 * @param settings
-	 * @since 0.6.1
+	 * @since 0.7.0
 	 */
 	@Override
 	public void setup(IStoredSettings settings) {
@@ -181,6 +185,12 @@
 				model.addRepository(role);
 			}
 		}
+		// set the teams for the user
+		for (TeamModel team : teams.values()) {
+			if (team.hasUser(username)) {
+				model.teams.add(DeepCopier.copy(team));
+			}
+		}
 		return model;
 	}
 
@@ -209,6 +219,7 @@
 	public boolean updateUserModel(String username, UserModel model) {
 		try {
 			Properties allUsers = read();
+			UserModel oldUser = getUserModel(username);
 			ArrayList<String> roles = new ArrayList<String>(model.repositories);
 
 			// Permissions
@@ -230,6 +241,32 @@
 			sb.setLength(sb.length() - 1);
 			allUsers.remove(username);
 			allUsers.put(model.username, sb.toString());
+
+			// null check on "final" teams because JSON-sourced UserModel
+			// can have a null teams object
+			if (model.teams != null) {
+				// update team cache
+				for (TeamModel team : model.teams) {
+					TeamModel t = getTeamModel(team.name);
+					if (t == null) {
+						// new team
+						t = team;
+					}
+					t.removeUser(username);
+					t.addUser(model.username);
+					updateTeamCache(allUsers, t.name, t);
+				}
+
+				// check for implicit team removal
+				if (oldUser != null) {
+					for (TeamModel team : oldUser.teams) {
+						if (!model.isTeamMember(team.name)) {
+							team.removeUser(username);
+							updateTeamCache(allUsers, team.name, team);
+						}
+					}
+				}
+			}
 
 			write(allUsers);
 			return true;
@@ -262,7 +299,17 @@
 		try {
 			// Read realm file
 			Properties allUsers = read();
+			UserModel user = getUserModel(username);
 			allUsers.remove(username);
+			for (TeamModel team : user.teams) {
+				TeamModel t = getTeamModel(team.name);
+				if (t == null) {
+					// new team
+					t = team;
+				}
+				t.removeUser(username);
+				updateTeamCache(allUsers, t.name, t);
+			}
 			write(allUsers);
 			return true;
 		} catch (Throwable t) {
@@ -279,7 +326,14 @@
 	@Override
 	public List<String> getAllUsernames() {
 		Properties allUsers = read();
-		List<String> list = new ArrayList<String>(allUsers.stringPropertyNames());
+		List<String> list = new ArrayList<String>();
+		for (String user : allUsers.stringPropertyNames()) {
+			if (user.charAt(0) == '@') {
+				// skip team user definitions
+				continue;
+			}
+			list.add(user);
+		}
 		return list;
 	}
 
@@ -297,6 +351,9 @@
 		try {
 			Properties allUsers = read();
 			for (String username : allUsers.stringPropertyNames()) {
+				if (username.charAt(0) == '@') {
+					continue;
+				}
 				String value = allUsers.getProperty(username);
 				String[] values = value.split(",");
 				// skip first value (password)
@@ -315,7 +372,7 @@
 	}
 
 	/**
-	 * Sets the list of all uses who are allowed to bypass the access
+	 * Sets the list of all users who are allowed to bypass the access
 	 * restriction placed on the specified repository.
 	 * 
 	 * @param role
@@ -426,7 +483,7 @@
 				sb.append(',');
 				sb.append(newRole);
 				sb.append(',');
-				
+
 				// skip first value (password)
 				for (int i = 1; i < values.length; i++) {
 					String value = values[i];
@@ -520,7 +577,7 @@
 		FileWriter writer = new FileWriter(realmFileCopy);
 		properties
 				.store(writer,
-						"# Gitblit realm file format: username=password,\\#permission,repository1,repository2...");
+						" Gitblit realm file format:\n   username=password,\\#permission,repository1,repository2...\n   @teamname=!username1,!username2,!username3,repository1,repository2...");
 		writer.close();
 		// If the write is successful, delete the current file and rename
 		// the temporary copy to the original filename.
@@ -551,11 +608,31 @@
 		if (lastRead != lastModified()) {
 			// reload hash cache
 			cookies.clear();
+			teams.clear();
+
 			for (String username : allUsers.stringPropertyNames()) {
 				String value = allUsers.getProperty(username);
 				String[] roles = value.split(",");
-				String password = roles[0];
-				cookies.put(StringUtils.getSHA1(username + password), username);
+				if (username.charAt(0) == '@') {
+					// team definition
+					TeamModel team = new TeamModel(username.substring(1));
+					List<String> repositories = new ArrayList<String>();
+					List<String> users = new ArrayList<String>();
+					for (String role : roles) {
+						if (role.charAt(0) == '!') {
+							users.add(role.substring(1));
+						} else {
+							repositories.add(role);
+						}
+					}
+					team.addRepositories(repositories);
+					team.addUsers(users);
+					teams.put(team.name.toLowerCase(), team);
+				} else {
+					// user definition
+					String password = roles[0];
+					cookies.put(StringUtils.getSHA1(username + password), username);
+				}
 			}
 		}
 		return allUsers;
@@ -565,4 +642,236 @@
 	public String toString() {
 		return getClass().getSimpleName() + "(" + propertiesFile.getAbsolutePath() + ")";
 	}
+
+	/**
+	 * Returns the list of all teams available to the login service.
+	 * 
+	 * @return list of all teams
+	 * @since 0.8.0
+	 */
+	@Override
+	public List<String> getAllTeamNames() {
+		List<String> list = new ArrayList<String>(teams.keySet());
+		return list;
+	}
+
+	/**
+	 * Returns the list of all teams who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @return list of all teamnames that can bypass the access restriction
+	 */
+	@Override
+	public List<String> getTeamnamesForRepositoryRole(String role) {
+		List<String> list = new ArrayList<String>();
+		try {
+			Properties allUsers = read();
+			for (String team : allUsers.stringPropertyNames()) {
+				if (team.charAt(0) != '@') {
+					// skip users
+					continue;
+				}
+				String value = allUsers.getProperty(team);
+				String[] values = value.split(",");
+				for (int i = 0; i < values.length; i++) {
+					String r = values[i];
+					if (r.equalsIgnoreCase(role)) {
+						// strip leading @
+						list.add(team.substring(1));
+						break;
+					}
+				}
+			}
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to get teamnames for role {0}!", role), t);
+		}
+		return list;
+	}
+
+	/**
+	 * Sets the list of all teams who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @param teamnames
+	 * @return true if successful
+	 */
+	@Override
+	public boolean setTeamnamesForRepositoryRole(String role, List<String> teamnames) {
+		try {
+			Set<String> specifiedTeams = new HashSet<String>(teamnames);
+			Set<String> needsAddRole = new HashSet<String>(specifiedTeams);
+			Set<String> needsRemoveRole = new HashSet<String>();
+
+			// identify teams which require add and remove role
+			Properties allUsers = read();
+			for (String team : allUsers.stringPropertyNames()) {
+				if (team.charAt(0) != '@') {
+					// skip users
+					continue;
+				}
+				String name = team.substring(1);
+				String value = allUsers.getProperty(team);
+				String[] values = value.split(",");
+				for (int i = 0; i < values.length; i++) {
+					String r = values[i];
+					if (r.equalsIgnoreCase(role)) {
+						// team has role, check against revised team list
+						if (specifiedTeams.contains(name)) {
+							needsAddRole.remove(name);
+						} else {
+							// remove role from team
+							needsRemoveRole.add(name);
+						}
+						break;
+					}
+				}
+			}
+
+			// add roles to teams
+			for (String name : needsAddRole) {
+				String team = "@" + name;
+				String teamValues = allUsers.getProperty(team);
+				teamValues += "," + role;
+				allUsers.put(team, teamValues);
+			}
+
+			// remove role from team
+			for (String name : needsRemoveRole) {
+				String team = "@" + name;
+				String[] values = allUsers.getProperty(team).split(",");				
+				StringBuilder sb = new StringBuilder();
+				for (int i = 0; i < values.length; i++) {
+					String value = values[i];
+					if (!value.equalsIgnoreCase(role)) {
+						sb.append(value);
+						sb.append(',');
+					}
+				}
+				sb.setLength(sb.length() - 1);
+
+				// update properties
+				allUsers.put(team, sb.toString());
+			}
+
+			// persist changes
+			write(allUsers);
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to set teamnames for role {0}!", role), t);
+		}
+		return false;
+	}
+
+	/**
+	 * Retrieve the team object for the specified team name.
+	 * 
+	 * @param teamname
+	 * @return a team object or null
+	 * @since 0.8.0
+	 */
+	@Override
+	public TeamModel getTeamModel(String teamname) {
+		read();
+		TeamModel team = teams.get(teamname.toLowerCase());
+		if (team != null) {
+			// clone the model, otherwise all changes to this object are
+			// live and unpersisted
+			team = DeepCopier.copy(team);
+		}
+		return team;
+	}
+
+	/**
+	 * Updates/writes a complete team object.
+	 * 
+	 * @param model
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean updateTeamModel(TeamModel model) {
+		return updateTeamModel(model.name, model);
+	}
+
+	/**
+	 * Updates/writes and replaces a complete team object keyed by teamname.
+	 * This method allows for renaming a team.
+	 * 
+	 * @param teamname
+	 *            the old teamname
+	 * @param model
+	 *            the team object to use for teamname
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean updateTeamModel(String teamname, TeamModel model) {
+		try {
+			Properties allUsers = read();
+			updateTeamCache(allUsers, teamname, model);
+			write(allUsers);
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to update team model {0}!", model.name), t);
+		}
+		return false;
+	}
+
+	private void updateTeamCache(Properties allUsers, String teamname, TeamModel model) {
+		StringBuilder sb = new StringBuilder();
+		for (String repository : model.repositories) {
+			sb.append(repository);
+			sb.append(',');
+		}
+		for (String user : model.users) {
+			sb.append('!');
+			sb.append(user);
+			sb.append(',');
+		}
+		// trim trailing comma
+		sb.setLength(sb.length() - 1);
+		allUsers.remove("@" + teamname);
+		allUsers.put("@" + model.name, sb.toString());
+
+		// update team cache
+		teams.remove(teamname.toLowerCase());
+		teams.put(model.name.toLowerCase(), model);
+	}
+
+	/**
+	 * Deletes the team object from the user service.
+	 * 
+	 * @param model
+	 * @return true if successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean deleteTeamModel(TeamModel model) {
+		return deleteTeam(model.name);
+	}
+
+	/**
+	 * Delete the team object with the specified teamname
+	 * 
+	 * @param teamname
+	 * @return true if successful
+	 * @since 0.8.0
+	 */
+	@Override
+	public boolean deleteTeam(String teamname) {
+		Properties allUsers = read();
+		teams.remove(teamname.toLowerCase());
+		allUsers.remove("@" + teamname);
+		try {
+			write(allUsers);
+			return true;
+		} catch (Throwable t) {
+			logger.error(MessageFormat.format("Failed to delete team {0}!", teamname), t);
+		}
+		return false;
+	}
 }
diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java
index 60a96e6..13dc3fa 100644
--- a/src/com/gitblit/GitBlit.java
+++ b/src/com/gitblit/GitBlit.java
@@ -69,6 +69,7 @@
 import com.gitblit.models.ServerSettings;
 import com.gitblit.models.ServerStatus;
 import com.gitblit.models.SettingModel;
+import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
 import com.gitblit.utils.ByteFormat;
 import com.gitblit.utils.FederationUtils;
@@ -508,6 +509,83 @@
 		if (!userService.updateUserModel(username, user)) {
 			throw new GitBlitException(isCreate ? "Failed to add user!" : "Failed to update user!");
 		}
+	}
+
+	/**
+	 * Returns the list of available teams that a user or repository may be
+	 * assigned to.
+	 * 
+	 * @return the list of teams
+	 */
+	public List<String> getAllTeamnames() {
+		List<String> teams = new ArrayList<String>(userService.getAllTeamNames());
+		Collections.sort(teams);
+		return teams;
+	}
+
+	/**
+	 * Returns the TeamModel object for the specified name.
+	 * 
+	 * @param teamname
+	 * @return a TeamModel object or null
+	 */
+	public TeamModel getTeamModel(String teamname) {
+		return userService.getTeamModel(teamname);
+	}
+
+	/**
+	 * Returns the list of all teams who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @see IUserService.getTeamnamesForRepositoryRole(String)
+	 * @param repository
+	 * @return list of all teamnames that can bypass the access restriction
+	 */
+	public List<String> getRepositoryTeams(RepositoryModel repository) {
+		return userService.getTeamnamesForRepositoryRole(repository.name);
+	}
+
+	/**
+	 * Sets the list of all uses who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @see IUserService.setTeamnamesForRepositoryRole(String, List<String>)
+	 * @param repository
+	 * @param teamnames
+	 * @return true if successful
+	 */
+	public boolean setRepositoryTeams(RepositoryModel repository, List<String> repositoryTeams) {
+		return userService.setTeamnamesForRepositoryRole(repository.name, repositoryTeams);
+	}
+	/**
+	 * Updates the TeamModel object for the specified name.
+	 * 
+	 * @param teamname
+	 * @param team
+	 * @param isCreate
+	 */
+	public void updateTeamModel(String teamname, TeamModel team, boolean isCreate) throws GitBlitException {
+		if (!teamname.equalsIgnoreCase(team.name)) {
+			if (userService.getTeamModel(team.name) != null) {
+				throw new GitBlitException(MessageFormat.format(
+						"Failed to rename ''{0}'' because ''{1}'' already exists.", teamname,
+						team.name));
+			}
+		}
+		if (!userService.updateTeamModel(teamname, team)) {
+			throw new GitBlitException(isCreate ? "Failed to add team!" : "Failed to update team!");
+		}
+	}
+	
+	/**
+	 * Delete the team object with the specified teamname
+	 * 
+	 * @see IUserService.deleteTeam(String)
+	 * @param teamname
+	 * @return true if successful
+	 */
+	public boolean deleteTeam(String teamname) {
+		return userService.deleteTeam(teamname);
 	}
 
 	/**
@@ -1115,6 +1193,7 @@
 		case PULL_REPOSITORIES:
 			return token.equals(all) || token.equals(unr) || token.equals(jur);
 		case PULL_USERS:
+		case PULL_TEAMS:
 			return token.equals(all) || token.equals(unr);
 		case PULL_SETTINGS:
 			return token.equals(all);
@@ -1473,7 +1552,7 @@
 							configService.updateUserModel(userModel);
 						}
 					}
-					
+
 					// issue suggestion about switching to users.conf
 					logger.warn("Please consider using \"users.conf\" instead of the deprecated \"users.properties\" file");
 				} else if (realmFile.getName().toLowerCase().endsWith(".conf")) {
diff --git a/src/com/gitblit/IUserService.java b/src/com/gitblit/IUserService.java
index e143c79..98dbf0d 100644
--- a/src/com/gitblit/IUserService.java
+++ b/src/com/gitblit/IUserService.java
@@ -17,6 +17,7 @@
 
 import java.util.List;
 
+import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
 
 /**
@@ -122,6 +123,84 @@
 	List<String> getAllUsernames();
 
 	/**
+	 * Returns the list of all teams available to the login service.
+	 * 
+	 * @return list of all teams
+	 * @since 0.8.0
+	 */	
+	List<String> getAllTeamNames();
+	
+	/**
+	 * Returns the list of all users who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @return list of all usernames that can bypass the access restriction
+	 */	
+	List<String> getTeamnamesForRepositoryRole(String role);
+
+	/**
+	 * Sets the list of all teams who are allowed to bypass the access
+	 * restriction placed on the specified repository.
+	 * 
+	 * @param role
+	 *            the repository name
+	 * @param teamnames
+	 * @return true if successful
+	 */	
+	boolean setTeamnamesForRepositoryRole(String role, List<String> teamnames);
+	
+	/**
+	 * Retrieve the team object for the specified team name.
+	 * 
+	 * @param teamname
+	 * @return a team object or null
+	 * @since 0.8.0
+	 */	
+	TeamModel getTeamModel(String teamname);
+
+	/**
+	 * Updates/writes a complete team object.
+	 * 
+	 * @param model
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */	
+	boolean updateTeamModel(TeamModel model);
+
+	/**
+	 * Updates/writes and replaces a complete team object keyed by teamname.
+	 * This method allows for renaming a team.
+	 * 
+	 * @param teamname
+	 *            the old teamname
+	 * @param model
+	 *            the team object to use for teamname
+	 * @return true if update is successful
+	 * @since 0.8.0
+	 */
+	boolean updateTeamModel(String teamname, TeamModel model);
+
+	/**
+	 * Deletes the team object from the user service.
+	 * 
+	 * @param model
+	 * @return true if successful
+	 * @since 0.8.0
+	 */
+	boolean deleteTeamModel(TeamModel model);
+
+	/**
+	 * Delete the team object with the specified teamname
+	 * 
+	 * @param teamname
+	 * @return true if successful
+	 * @since 0.8.0
+	 */	
+	boolean deleteTeam(String teamname);
+
+	/**
 	 * Returns the list of all users who are allowed to bypass the access
 	 * restriction placed on the specified repository.
 	 * 
diff --git a/src/com/gitblit/models/TeamModel.java b/src/com/gitblit/models/TeamModel.java
new file mode 100644
index 0000000..195b9d5
--- /dev/null
+++ b/src/com/gitblit/models/TeamModel.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2011 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.models;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * TeamModel is a serializable model class that represents a group of users and
+ * a list of accessible repositories.
+ * 
+ * @author James Moger
+ * 
+ */
+public class TeamModel implements Serializable, Comparable<TeamModel> {
+
+	private static final long serialVersionUID = 1L;
+
+	// field names are reflectively mapped in EditTeam page
+	public String name;
+	public final Set<String> users = new HashSet<String>();
+	public final Set<String> repositories = new HashSet<String>();
+
+	public TeamModel(String name) {
+		this.name = name;
+	}
+
+	public boolean hasRepository(String name) {
+		return repositories.contains(name.toLowerCase());
+	}
+
+	public void addRepository(String name) {
+		repositories.add(name.toLowerCase());
+	}
+	
+	public void addRepositories(Collection<String> names) {
+		for (String name:names) {
+			repositories.add(name.toLowerCase());
+		}
+	}	
+
+	public void removeRepository(String name) {
+		repositories.remove(name.toLowerCase());
+	}
+	
+	public boolean hasUser(String name) {
+		return users.contains(name.toLowerCase());
+	}
+
+	public void addUser(String name) {
+		users.add(name.toLowerCase());
+	}
+
+	public void addUsers(Collection<String> names) {
+		for (String name:names) {
+			users.add(name.toLowerCase());
+		}
+	}
+
+	public void removeUser(String name) {
+		users.remove(name.toLowerCase());
+	}
+
+	@Override
+	public String toString() {
+		return name;
+	}
+
+	@Override
+	public int compareTo(TeamModel o) {
+		return name.compareTo(o.name);
+	}
+}
diff --git a/src/com/gitblit/models/UserModel.java b/src/com/gitblit/models/UserModel.java
index 8c99512..bd8974d 100644
--- a/src/com/gitblit/models/UserModel.java
+++ b/src/com/gitblit/models/UserModel.java
@@ -40,6 +40,7 @@
 	public boolean canAdmin;
 	public boolean excludeFromFederation;
 	public final Set<String> repositories = new HashSet<String>();
+	public final Set<TeamModel> teams = new HashSet<TeamModel>();
 
 	public UserModel(String username) {
 		this.username = username;
@@ -54,13 +55,24 @@
 	 */
 	@Deprecated
 	public boolean canAccessRepository(String repositoryName) {
-		return canAdmin || repositories.contains(repositoryName.toLowerCase());
+		return canAdmin || repositories.contains(repositoryName.toLowerCase())
+				|| hasTeamAccess(repositoryName);
 	}
 
 	public boolean canAccessRepository(RepositoryModel repository) {
 		boolean isOwner = !StringUtils.isEmpty(repository.owner)
 				&& repository.owner.equals(username);
-		return canAdmin || isOwner || repositories.contains(repository.name.toLowerCase());
+		return canAdmin || isOwner || repositories.contains(repository.name.toLowerCase())
+				|| hasTeamAccess(repository.name);
+	}
+
+	public boolean hasTeamAccess(String repositoryName) {
+		for (TeamModel team : teams) {
+			if (team.hasRepository(repositoryName)) {
+				return true;
+			}
+		}
+		return false;
 	}
 
 	public boolean hasRepository(String name) {
@@ -75,6 +87,15 @@
 		repositories.remove(name.toLowerCase());
 	}
 
+	public boolean isTeamMember(String teamname) {
+		for (TeamModel team : teams) {
+			if (team.name.equalsIgnoreCase(teamname)) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	@Override
 	public String getName() {
 		return username;
diff --git a/src/com/gitblit/utils/DeepCopier.java b/src/com/gitblit/utils/DeepCopier.java
new file mode 100644
index 0000000..5df3062
--- /dev/null
+++ b/src/com/gitblit/utils/DeepCopier.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2011 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.utils;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+
+public class DeepCopier {
+
+	/**
+	 * Produce a deep copy of the given object. Serializes the entire object to
+	 * a byte array in memory. Recommended for relatively small objects.
+	 */
+	@SuppressWarnings("unchecked")
+	public static <T> T copy(T original) {
+		T o = null;
+		try {
+			ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
+			ObjectOutputStream oos = new ObjectOutputStream(byteOut);
+			oos.writeObject(original);
+			ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
+			ObjectInputStream ois = new ObjectInputStream(byteIn);
+			try {
+				o = (T) ois.readObject();
+			} catch (ClassNotFoundException cex) {
+				// actually can not happen in this instance
+			}
+		} catch (IOException iox) {
+			// doesn't seem likely to happen as these streams are in memory
+			throw new RuntimeException(iox);
+		}
+		return o;
+	}
+
+	/**
+	 * This conserves heap memory!!!!! Produce a deep copy of the given object.
+	 * Serializes the object through a pipe between two threads. Recommended for
+	 * very large objects. The current thread is used for serializing the
+	 * original object in order to respect any synchronization the caller may
+	 * have around it, and a new thread is used for deserializing the copy.
+	 * 
+	 */
+	public static <T> T copyParallel(T original) {
+		try {
+			PipedOutputStream outputStream = new PipedOutputStream();
+			PipedInputStream inputStream = new PipedInputStream(outputStream);
+			ObjectOutputStream ois = new ObjectOutputStream(outputStream);
+			Receiver<T> receiver = new Receiver<T>(inputStream);
+			try {
+				ois.writeObject(original);
+			} finally {
+				ois.close();
+			}
+			return receiver.getResult();
+		} catch (IOException iox) {
+			// doesn't seem likely to happen as these streams are in memory
+			throw new RuntimeException(iox);
+		}
+	}
+
+	private static class Receiver<T> extends Thread {
+
+		private final InputStream inputStream;
+		private volatile T result;
+		private volatile Throwable throwable;
+
+		public Receiver(InputStream inputStream) {
+			this.inputStream = inputStream;
+			start();
+		}
+
+		@SuppressWarnings("unchecked")
+		public void run() {
+
+			try {
+				ObjectInputStream ois = new ObjectInputStream(inputStream);
+				try {
+					result = (T) ois.readObject();
+					try {
+						// Some serializers may write more than they actually
+						// need to deserialize the object, but if we don't
+						// read it all the PipedOutputStream will choke.
+						while (inputStream.read() != -1) {
+						}
+					} catch (IOException e) {
+						// The object has been successfully deserialized, so
+						// ignore problems at this point (for example, the
+						// serializer may have explicitly closed the inputStream
+						// itself, causing this read to fail).
+					}
+				} finally {
+					ois.close();
+				}
+			} catch (Throwable t) {
+				throwable = t;
+			}
+		}
+
+		public T getResult() throws IOException {
+			try {
+				join();
+			} catch (InterruptedException e) {
+				throw new RuntimeException("Unexpected InterruptedException", e);
+			}
+			// join() guarantees that all shared memory is synchronized between
+			// the two threads
+			if (throwable != null) {
+				if (throwable instanceof ClassNotFoundException) {
+					// actually can not happen in this instance
+				}
+				throw new RuntimeException(throwable);
+			}
+			return result;
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/GitBlitWebApp.properties b/src/com/gitblit/wicket/GitBlitWebApp.properties
index 7ffdb59..6f32168 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -187,4 +187,10 @@
 gb.dailyActivity = daily activity
 gb.activeRepositories = active repositories
 gb.activeAuthors = active authors
-gb.commits = commits
\ No newline at end of file
+gb.commits = commits
+gb.teams = teams
+gb.teamName = team name
+gb.teamMembers = team members
+gb.teamMemberships = team memberships
+gb.newTeam = new team
+gb.permittedTeams = permitted teams
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/WicketUtils.java b/src/com/gitblit/wicket/WicketUtils.java
index e8c5e15..dbeb47f 100644
--- a/src/com/gitblit/wicket/WicketUtils.java
+++ b/src/com/gitblit/wicket/WicketUtils.java
@@ -259,6 +259,10 @@
 		return new PageParameters("user=" + username);
 	}
 
+	public static PageParameters newTeamnameParameter(String teamname) {
+		return new PageParameters("team=" + teamname);
+	}
+
 	public static PageParameters newRepositoryParameter(String repositoryName) {
 		return new PageParameters("r=" + repositoryName);
 	}
@@ -377,6 +381,10 @@
 		return params.getString("user", "");
 	}
 
+	public static String getTeamname(PageParameters params) {
+		return params.getString("team", "");
+	}
+
 	public static String getToken(PageParameters params) {
 		return params.getString("t", "");
 	}
diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/com/gitblit/wicket/pages/EditRepositoryPage.html
index 27a5448..9e22189 100644
--- a/src/com/gitblit/wicket/pages/EditRepositoryPage.html
+++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.html
@@ -7,7 +7,7 @@
 <wicket:extend>
 <body onload="document.getElementById('name').focus();">
 	<!-- Repository Table -->
-	<form wicket:id="editForm">
+	<form style="padding-top:5px;" wicket:id="editForm">
 		<table class="plain">
 			<tbody>
 				<tr><th><wicket:message key="gb.name"></wicket:message></th><td class="edit"><input class="span6" type="text" wicket:id="name" id="name" size="40" tabindex="1" /> &nbsp;<i><wicket:message key="gb.nameDescription"></wicket:message></i></td></tr>
@@ -24,6 +24,7 @@
 				<tr><td colspan="2"><hr></hr></td></tr>
 				<tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span6" wicket:id="accessRestriction" tabindex="12" /></td></tr>				
 				<tr><th style="vertical-align: top;"><wicket:message key="gb.permittedUsers"></wicket:message></th><td style="padding:2px;"><span wicket:id="users"></span></td></tr>
+				<tr><th style="vertical-align: top;"><wicket:message key="gb.permittedTeams"></wicket:message></th><td style="padding:2px;"><span wicket:id="teams"></span></td></tr>
 				<tr><td colspan="2"><hr></hr></td></tr>		
 				<tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span6" wicket:id="federationStrategy" tabindex="13" /></td></tr>
 				<tr><th style="vertical-align: top;"><wicket:message key="gb.federationSets"></wicket:message></th><td style="padding:2px;"><span wicket:id="federationSets"></span></td></tr>
diff --git a/src/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/com/gitblit/wicket/pages/EditRepositoryPage.java
index be88bd5..1a5ec3d 100644
--- a/src/com/gitblit/wicket/pages/EditRepositoryPage.java
+++ b/src/com/gitblit/wicket/pages/EditRepositoryPage.java
@@ -75,12 +75,14 @@
 
 		List<String> federationSets = new ArrayList<String>();
 		List<String> repositoryUsers = new ArrayList<String>();
+		List<String> repositoryTeams = new ArrayList<String>();
 		if (isCreate) {
 			super.setupPage(getString("gb.newRepository"), "");
 		} else {
 			super.setupPage(getString("gb.edit"), repositoryModel.name);
 			if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
 				repositoryUsers.addAll(GitBlit.self().getRepositoryUsers(repositoryModel));
+				repositoryTeams.addAll(GitBlit.self().getRepositoryTeams(repositoryModel));
 				Collections.sort(repositoryUsers);
 			}
 			federationSets.addAll(repositoryModel.federationSets);
@@ -91,6 +93,11 @@
 		// users palette
 		final Palette<String> usersPalette = new Palette<String>("users", new ListModel<String>(
 				repositoryUsers), new CollectionModel<String>(GitBlit.self().getAllUsernames()),
+				new ChoiceRenderer<String>("", ""), 10, false);
+
+		// teams palette
+		final Palette<String> teamsPalette = new Palette<String>("teams", new ListModel<String>(
+				repositoryTeams), new CollectionModel<String>(GitBlit.self().getAllTeamnames()),
 				new ChoiceRenderer<String>("", ""), 10, false);
 
 		// federation sets palette
@@ -165,8 +172,9 @@
 					// save the repository
 					GitBlit.self().updateRepositoryModel(oldName, repositoryModel, isCreate);
 
-					// save the repository access list
+					// repository access
 					if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
+						// save the user access list
 						Iterator<String> users = usersPalette.getSelectedChoices();
 						List<String> repositoryUsers = new ArrayList<String>();
 						while (users.hasNext()) {
@@ -178,6 +186,14 @@
 							repositoryUsers.add(repositoryModel.owner);
 						}
 						GitBlit.self().setRepositoryUsers(repositoryModel, repositoryUsers);
+						
+						// save the team access list
+						Iterator<String> teams = teamsPalette.getSelectedChoices();
+						List<String> repositoryTeams = new ArrayList<String>();
+						while (teams.hasNext()) {
+							repositoryTeams.add(teams.next());
+						}
+						GitBlit.self().setRepositoryTeams(repositoryModel, repositoryTeams);
 					}
 				} catch (GitBlitException e) {
 					error(e.getMessage());
@@ -215,6 +231,7 @@
 		form.add(new CheckBox("skipSizeCalculation"));
 		form.add(new CheckBox("skipSummaryMetrics"));
 		form.add(usersPalette);
+		form.add(teamsPalette);
 		form.add(federationSetsPalette);
 
 		form.add(new Button("save"));
diff --git a/src/com/gitblit/wicket/pages/EditTeamPage.html b/src/com/gitblit/wicket/pages/EditTeamPage.html
new file mode 100644
index 0000000..84a53e3
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/EditTeamPage.html
@@ -0,0 +1,24 @@
+<!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"> 
+
+<wicket:extend>
+<body onload="document.getElementById('name').focus();">
+	<!-- User Table -->
+	<form style="padding-top:5px;" wicket:id="editForm">
+		<table class="plain">
+			<tbody>
+				<tr><th><wicket:message key="gb.teamName"></wicket:message></th><td class="edit"><input type="text" wicket:id="name" id="name" size="30" tabindex="1" /></td></tr>
+				<tr><td colspan="2"><hr></hr></td></tr>
+				<tr><th style="vertical-align: top;"><wicket:message key="gb.teamMembers"></wicket:message></th><td style="padding:2px;"><span wicket:id="users"></span></td></tr>
+				<tr><td colspan="2"><hr></hr></td></tr>
+				<tr><th style="vertical-align: top;"><wicket:message key="gb.restrictedRepositories"></wicket:message></th><td style="padding:2px;"><span wicket:id="repositories"></span></td></tr>
+				<tr><th></th><td class="editButton"><input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" tabindex="3" /> &nbsp; <input class="btn primary" type="submit" value="Save" wicket:message="value:gb.save" wicket:id="save" tabindex="4" /></td></tr>
+			</tbody>
+		</table>
+	</form>	
+</body>
+</wicket:extend>
+</html>
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/pages/EditTeamPage.java b/src/com/gitblit/wicket/pages/EditTeamPage.java
new file mode 100644
index 0000000..47f3568
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/EditTeamPage.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2011 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.pages;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.extensions.markup.html.form.palette.Palette;
+import org.apache.wicket.markup.html.form.Button;
+import org.apache.wicket.markup.html.form.ChoiceRenderer;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.model.CompoundPropertyModel;
+import org.apache.wicket.model.util.CollectionModel;
+import org.apache.wicket.model.util.ListModel;
+
+import com.gitblit.Constants.AccessRestrictionType;
+import com.gitblit.GitBlit;
+import com.gitblit.GitBlitException;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TeamModel;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.RequiresAdminRole;
+import com.gitblit.wicket.WicketUtils;
+
+@RequiresAdminRole
+public class EditTeamPage extends RootSubPage {
+
+	private final boolean isCreate;
+
+	public EditTeamPage() {
+		// create constructor
+		super();
+		isCreate = true;
+		setupPage(new TeamModel(""));
+	}
+
+	public EditTeamPage(PageParameters params) {
+		// edit constructor
+		super(params);
+		isCreate = false;
+		String name = WicketUtils.getTeamname(params);
+		TeamModel model = GitBlit.self().getTeamModel(name);
+		setupPage(model);
+	}
+
+	protected void setupPage(final TeamModel teamModel) {
+		if (isCreate) {
+			super.setupPage(getString("gb.newTeam"), "");
+		} else {
+			super.setupPage(getString("gb.edit"), teamModel.name);
+		}
+		
+		CompoundPropertyModel<TeamModel> model = new CompoundPropertyModel<TeamModel>(teamModel);
+
+		List<String> repos = new ArrayList<String>();
+		for (String repo : GitBlit.self().getRepositoryList()) {
+			RepositoryModel repositoryModel = GitBlit.self().getRepositoryModel(repo);
+			if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
+				repos.add(repo);
+			}
+		}
+		StringUtils.sortRepositorynames(repos);
+		
+		List<String> teamUsers = new ArrayList<String>(teamModel.users);
+		Collections.sort(teamUsers);
+		
+		final String oldName = teamModel.name;
+		final Palette<String> repositories = new Palette<String>("repositories",
+				new ListModel<String>(new ArrayList<String>(teamModel.repositories)),
+				new CollectionModel<String>(repos), new ChoiceRenderer<String>("", ""), 10, false);
+		final Palette<String> users = new Palette<String>("users", new ListModel<String>(
+				new ArrayList<String>(teamUsers)), new CollectionModel<String>(GitBlit.self()
+				.getAllUsernames()), new ChoiceRenderer<String>("", ""), 10, false);
+		Form<TeamModel> form = new Form<TeamModel>("editForm", model) {
+
+			private static final long serialVersionUID = 1L;
+
+			/*
+			 * (non-Javadoc)
+			 * 
+			 * @see org.apache.wicket.markup.html.form.Form#onSubmit()
+			 */
+			@Override
+			protected void onSubmit() {
+				String teamname = teamModel.name;
+				if (StringUtils.isEmpty(teamname)) {
+					error("Please enter a teamname!");
+					return;
+				}
+				if (isCreate) {
+					TeamModel model = GitBlit.self().getTeamModel(teamname);
+					if (model != null) {
+						error(MessageFormat.format("Team name ''{0}'' is unavailable.", teamname));
+						return;
+					}
+				}
+				Iterator<String> selectedRepositories = repositories.getSelectedChoices();
+				List<String> repos = new ArrayList<String>();
+				while (selectedRepositories.hasNext()) {
+					repos.add(selectedRepositories.next().toLowerCase());
+				}
+				teamModel.repositories.clear();
+				teamModel.repositories.addAll(repos);
+
+				Iterator<String> selectedUsers = users.getSelectedChoices();
+				List<String> members = new ArrayList<String>();
+				while (selectedUsers.hasNext()) {
+					members.add(selectedUsers.next().toLowerCase());
+				}
+				teamModel.users.clear();
+				teamModel.users.addAll(members);
+
+				try {
+					GitBlit.self().updateTeamModel(oldName, teamModel, isCreate);
+				} catch (GitBlitException e) {
+					error(e.getMessage());
+					return;
+				}
+				setRedirect(false);
+				if (isCreate) {
+					// create another team
+					info(MessageFormat.format("New team ''{0}'' successfully created.",
+							teamModel.name));
+					setResponsePage(EditTeamPage.class);
+				} else {
+					// back to users page
+					setResponsePage(UsersPage.class);
+				}
+			}
+		};
+
+		// field names reflective match TeamModel fields
+		form.add(new TextField<String>("name"));
+		form.add(repositories);
+		form.add(users);
+
+		form.add(new Button("save"));
+		Button cancel = new Button("cancel") {
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			public void onSubmit() {
+				setResponsePage(UsersPage.class);
+			}
+		};
+		cancel.setDefaultFormProcessing(false);
+		form.add(cancel);
+
+		add(form);
+	}
+}
diff --git a/src/com/gitblit/wicket/pages/EditUserPage.html b/src/com/gitblit/wicket/pages/EditUserPage.html
index ceda3cb..978393b 100644
--- a/src/com/gitblit/wicket/pages/EditUserPage.html
+++ b/src/com/gitblit/wicket/pages/EditUserPage.html
@@ -7,7 +7,7 @@
 <wicket:extend>
 <body onload="document.getElementById('username').focus();">
 	<!-- User Table -->
-	<form wicket:id="editForm">
+	<form style="padding-top:5px;" wicket:id="editForm">
 		<table class="plain">
 			<tbody>
 				<tr><th><wicket:message key="gb.username"></wicket:message></th><td class="edit"><input type="text" wicket:id="username" id="username" size="30" tabindex="1" /></td></tr>
@@ -15,6 +15,9 @@
 				<tr><th><wicket:message key="gb.confirmPassword"></wicket:message></th><td class="edit"><input type="password" wicket:id="confirmPassword" size="30" tabindex="3" /></td></tr>
 				<tr><th><wicket:message key="gb.canAdmin"></wicket:message></th><td class="edit"><input type="checkbox" wicket:id="canAdmin" tabindex="6" /> &nbsp;<i><wicket:message key="gb.canAdminDescription"></wicket:message></i></td></tr>				
 				<tr><th><wicket:message key="gb.excludeFromFederation"></wicket:message></th><td class="edit"><input type="checkbox" wicket:id="excludeFromFederation" tabindex="7" /> &nbsp;<i><wicket:message key="gb.excludeFromFederationDescription"></wicket:message></i></td></tr>				
+				<tr><td colspan="2"><hr></hr></td></tr>
+				<tr><th style="vertical-align: top;"><wicket:message key="gb.teamMemberships"></wicket:message></th><td style="padding:2px;"><span wicket:id="teams"></span></td></tr>
+				<tr><td colspan="2"><hr></hr></td></tr>
 				<tr><th style="vertical-align: top;"><wicket:message key="gb.restrictedRepositories"></wicket:message></th><td style="padding:2px;"><span wicket:id="repositories"></span></td></tr>
 				<tr><th></th><td class="editButton"><input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" tabindex="8" /> &nbsp; <input class="btn primary" type="submit" value="Save" wicket:message="value:gb.save" wicket:id="save" tabindex="9" /></td></tr>
 			</tbody>
diff --git a/src/com/gitblit/wicket/pages/EditUserPage.java b/src/com/gitblit/wicket/pages/EditUserPage.java
index 8955e22..799cf01 100644
--- a/src/com/gitblit/wicket/pages/EditUserPage.java
+++ b/src/com/gitblit/wicket/pages/EditUserPage.java
@@ -17,6 +17,7 @@
 
 import java.text.MessageFormat;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 
@@ -38,6 +39,7 @@
 import com.gitblit.GitBlitException;
 import com.gitblit.Keys;
 import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
 import com.gitblit.utils.StringUtils;
 import com.gitblit.wicket.RequiresAdminRole;
@@ -82,10 +84,19 @@
 				repos.add(repo);
 			}
 		}
+		List<String> userTeams = new ArrayList<String>();
+		for (TeamModel team : userModel.teams) {
+			userTeams.add(team.name);
+		}
+		Collections.sort(userTeams);
+		
 		final String oldName = userModel.username;
 		final Palette<String> repositories = new Palette<String>("repositories",
 				new ListModel<String>(new ArrayList<String>(userModel.repositories)),
 				new CollectionModel<String>(repos), new ChoiceRenderer<String>("", ""), 10, false);
+		final Palette<String> teams = new Palette<String>("teams", new ListModel<String>(
+				new ArrayList<String>(userTeams)), new CollectionModel<String>(GitBlit.self()
+				.getAllTeamnames()), new ChoiceRenderer<String>("", ""), 10, false);
 		Form<UserModel> form = new Form<UserModel>("editForm", model) {
 
 			private static final long serialVersionUID = 1L;
@@ -109,7 +120,8 @@
 						return;
 					}
 				}
-				boolean rename = !StringUtils.isEmpty(oldName) && !oldName.equalsIgnoreCase(username);
+				boolean rename = !StringUtils.isEmpty(oldName)
+						&& !oldName.equalsIgnoreCase(username);
 				if (!userModel.password.equals(confirmPassword.getObject())) {
 					error("Passwords do not match!");
 					return;
@@ -154,6 +166,17 @@
 				}
 				userModel.repositories.clear();
 				userModel.repositories.addAll(repos);
+
+				Iterator<String> selectedTeams = teams.getSelectedChoices();
+				userModel.teams.clear();
+				while (selectedTeams.hasNext()) {
+					TeamModel team = GitBlit.self().getTeamModel(selectedTeams.next());
+					if (team == null) {
+						continue;
+					}
+					userModel.teams.add(team);
+				}
+
 				try {
 					GitBlit.self().updateUserModel(oldName, userModel, isCreate);
 				} catch (GitBlitException e) {
@@ -185,6 +208,7 @@
 		form.add(new CheckBox("canAdmin"));
 		form.add(new CheckBox("excludeFromFederation"));
 		form.add(repositories);
+		form.add(teams);
 
 		form.add(new Button("save"));
 		Button cancel = new Button("cancel") {
diff --git a/src/com/gitblit/wicket/pages/UsersPage.html b/src/com/gitblit/wicket/pages/UsersPage.html
index 4d14496..edb85f7 100644
--- a/src/com/gitblit/wicket/pages/UsersPage.html
+++ b/src/com/gitblit/wicket/pages/UsersPage.html
@@ -5,6 +5,8 @@
       lang="en"> 
 <body>
 <wicket:extend>
+	<div wicket:id="teamsPanel">[teams panel]</div>
+
 	<div wicket:id="usersPanel">[users panel]</div>
 </wicket:extend>
 </body>
diff --git a/src/com/gitblit/wicket/pages/UsersPage.java b/src/com/gitblit/wicket/pages/UsersPage.java
index b54b968..9526dea 100644
--- a/src/com/gitblit/wicket/pages/UsersPage.java
+++ b/src/com/gitblit/wicket/pages/UsersPage.java
@@ -16,6 +16,7 @@
 package com.gitblit.wicket.pages;
 
 import com.gitblit.wicket.RequiresAdminRole;
+import com.gitblit.wicket.panels.TeamsPanel;
 import com.gitblit.wicket.panels.UsersPanel;
 
 @RequiresAdminRole
@@ -25,6 +26,8 @@
 		super();		
 		setupPage("", "");
 
+		add(new TeamsPanel("teamsPanel", showAdmin).setVisible(showAdmin));
+		
 		add(new UsersPanel("usersPanel", showAdmin).setVisible(showAdmin));
 	}
 }
diff --git a/src/com/gitblit/wicket/panels/TeamsPanel.html b/src/com/gitblit/wicket/panels/TeamsPanel.html
new file mode 100644
index 0000000..af1d56d
--- /dev/null
+++ b/src/com/gitblit/wicket/panels/TeamsPanel.html
@@ -0,0 +1,44 @@
+<!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 wicket:id="adminPanel">[admin links]</div>
+		
+		<table class="repositories">
+		<tr>
+			<th class="left">
+				<img style="vertical-align: middle; border: 1px solid #888; background-color: white;" src="users_16x16.png"/>
+				<wicket:message key="gb.teams">[teams]</wicket:message>
+			</th>
+			<th class="right"></th>
+		</tr>
+		<tbody>		
+       		<tr wicket:id="teamRow">
+       			<td class="left" ><div class="list" wicket:id="teamname">[teamname]</div></td>
+       			<td class="rightAlign"><span wicket:id="teamLinks"></span></td>      			
+       		</tr>
+    	</tbody>
+	</table>
+	
+	<wicket:fragment wicket:id="adminLinks">
+		<!-- page nav links -->	
+		<div class="admin_nav">
+			<img style="vertical-align: middle;" src="add_16x16.png"/>
+			<a wicket:id="newTeam">
+				<wicket:message key="gb.newTeam"></wicket:message>
+			</a>
+		</div>	
+	</wicket:fragment>
+	
+	<wicket:fragment wicket:id="teamAdminLinks">
+		<span class="link"><a wicket:id="editTeam"><wicket:message key="gb.edit">[edit]</wicket:message></a> | <a wicket:id="deleteTeam"><wicket:message key="gb.delete">[delete]</wicket:message></a></span>
+	</wicket:fragment>
+	
+</wicket:panel>
+</body>
+</html>
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/panels/TeamsPanel.java b/src/com/gitblit/wicket/panels/TeamsPanel.java
new file mode 100644
index 0000000..33afb51
--- /dev/null
+++ b/src/com/gitblit/wicket/panels/TeamsPanel.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2011 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.text.MessageFormat;
+import java.util.List;
+
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;
+import org.apache.wicket.markup.html.link.Link;
+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 com.gitblit.GitBlit;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.pages.EditTeamPage;
+
+public class TeamsPanel extends BasePanel {
+
+	private static final long serialVersionUID = 1L;
+
+	public TeamsPanel(String wicketId, final boolean showAdmin) {
+		super(wicketId);
+
+		Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this);
+		adminLinks.add(new BookmarkablePageLink<Void>("newTeam", EditTeamPage.class));
+		add(adminLinks.setVisible(showAdmin));
+
+		final List<String> teamnames = GitBlit.self().getAllTeamnames();
+		DataView<String> teamsView = new DataView<String>("teamRow", new ListDataProvider<String>(
+				teamnames)) {
+			private static final long serialVersionUID = 1L;
+			private int counter;
+
+			@Override
+			protected void onBeforeRender() {
+				super.onBeforeRender();
+				counter = 0;
+			}
+
+			public void populateItem(final Item<String> item) {
+				final String entry = item.getModelObject();
+				LinkPanel editLink = new LinkPanel("teamname", "list", entry, EditTeamPage.class,
+						WicketUtils.newTeamnameParameter(entry));
+				WicketUtils.setHtmlTooltip(editLink, getString("gb.edit") + " " + entry);
+				item.add(editLink);
+				Fragment teamLinks = new Fragment("teamLinks", "teamAdminLinks", this);
+				teamLinks.add(new BookmarkablePageLink<Void>("editTeam", EditTeamPage.class,
+						WicketUtils.newTeamnameParameter(entry)));
+				Link<Void> deleteLink = new Link<Void>("deleteTeam") {
+
+					private static final long serialVersionUID = 1L;
+
+					@Override
+					public void onClick() {
+						if (GitBlit.self().deleteTeam(entry)) {
+							teamnames.remove(entry);
+							info(MessageFormat.format("Team ''{0}'' deleted.", entry));
+						} else {
+							error(MessageFormat.format("Failed to delete team ''{0}''!", entry));
+						}
+					}
+				};
+				deleteLink.add(new JavascriptEventConfirmation("onclick", MessageFormat.format(
+						"Delete team \"{0}\"?", entry)));
+				teamLinks.add(deleteLink);
+				item.add(teamLinks);
+
+				WicketUtils.setAlternatingBackground(item, counter);
+				counter++;
+			}
+		};
+		add(teamsView.setVisible(showAdmin));
+	}
+}
diff --git a/src/com/gitblit/wicket/panels/UsersPanel.html b/src/com/gitblit/wicket/panels/UsersPanel.html
index c81a3fd..1cc19ee 100644
--- a/src/com/gitblit/wicket/panels/UsersPanel.html
+++ b/src/com/gitblit/wicket/panels/UsersPanel.html
@@ -13,7 +13,7 @@
 		<tr>
 			<th class="left">
 				<img style="vertical-align: middle; border: 1px solid #888; background-color: white;" src="user_16x16.png"/>
-				<wicket:message key="gb.username">[username]</wicket:message>
+				<wicket:message key="gb.users">[users]</wicket:message>
 			</th>
 			<th class="right"></th>
 		</tr>
diff --git a/tests/com/gitblit/tests/UserServiceTest.java b/tests/com/gitblit/tests/UserServiceTest.java
index 3f410fa..93e7f60 100644
--- a/tests/com/gitblit/tests/UserServiceTest.java
+++ b/tests/com/gitblit/tests/UserServiceTest.java
@@ -38,6 +38,7 @@
 		file.delete();
 		IUserService service = new FileUserService(file);
 		testUsers(service);
+		testTeams(service);
 		file.delete();
 	}
 
@@ -47,6 +48,7 @@
 		file.delete();
 		IUserService service = new ConfigUserService(file);
 		testUsers(service);
+		testTeams(service);
 		file.delete();
 	}
 
@@ -106,4 +108,109 @@
 		testUser = service.getUserModel("test");
 		assertTrue(testUser.hasRepository("newrepo1"));
 	}
+
+	protected void testTeams(IUserService service) {
+
+		// confirm we have no teams
+		assertEquals(0, service.getAllTeamNames().size());
+
+		// remove newrepo1 from test user
+		// now test user has no repositories
+		UserModel user = service.getUserModel("test");
+		user.repositories.clear();
+		service.updateUserModel(user);
+		user = service.getUserModel("test");
+		assertEquals(0, user.repositories.size());
+		assertFalse(user.canAccessRepository("newrepo1"));
+		assertFalse(user.canAccessRepository("NEWREPO1"));
+
+		// create test team and add test user and newrepo1
+		TeamModel team = new TeamModel("testteam");
+		team.addUser("test");
+		team.addRepository("newrepo1");
+		service.updateTeamModel(team);
+
+		// confirm 1 user and 1 repo
+		team = service.getTeamModel("testteam");
+		assertEquals(1, team.repositories.size());
+		assertEquals(1, team.users.size());
+
+		// confirm team membership
+		user = service.getUserModel("test");
+		assertEquals(0, user.repositories.size());
+		assertEquals(1, user.teams.size());
+
+		// confirm team access
+		assertTrue(team.hasRepository("newrepo1"));
+		assertTrue(user.hasTeamAccess("newrepo1"));
+		assertTrue(team.hasRepository("NEWREPO1"));
+		assertTrue(user.hasTeamAccess("NEWREPO1"));
+
+		// rename the team and add new repository
+		team.addRepository("newrepo2");
+		team.name = "testteam2";
+		service.updateTeamModel("testteam", team);
+
+		team = service.getTeamModel("testteam2");
+		user = service.getUserModel("test");
+
+		// confirm user and team can access newrepo2
+		assertEquals(2, team.repositories.size());
+		assertTrue(team.hasRepository("newrepo2"));
+		assertTrue(user.hasTeamAccess("newrepo2"));
+		assertTrue(team.hasRepository("NEWREPO2"));
+		assertTrue(user.hasTeamAccess("NEWREPO2"));
+
+		// delete testteam2
+		service.deleteTeam("testteam2");
+		team = service.getTeamModel("testteam2");
+		user = service.getUserModel("test");
+
+		// confirm team does not exist and user can not access newrepo1 and 2
+		assertEquals(null, team);
+		assertFalse(user.canAccessRepository("newrepo1"));
+		assertFalse(user.canAccessRepository("newrepo2"));
+
+		// create new team and add it to user
+		// this tests the inverse team creation/team addition
+		team = new TeamModel("testteam");
+		team.addRepository("NEWREPO1");
+		team.addRepository("NEWREPO2");
+		user.teams.add(team);
+		service.updateUserModel(user);
+
+		// confirm the inverted team addition
+		user = service.getUserModel("test");
+		team = service.getTeamModel("testteam");
+		assertTrue(user.hasTeamAccess("newrepo1"));
+		assertTrue(user.hasTeamAccess("newrepo2"));
+		assertTrue(team.hasUser("test"));
+
+		// drop testteam from user and add nextteam to user
+		team = new TeamModel("nextteam");
+		team.addRepository("NEWREPO1");
+		team.addRepository("NEWREPO2");
+		user.teams.clear();
+		user.teams.add(team);
+		service.updateUserModel(user);
+
+		// confirm implicit drop
+		user = service.getUserModel("test");
+		team = service.getTeamModel("testteam");
+		assertTrue(user.hasTeamAccess("newrepo1"));
+		assertTrue(user.hasTeamAccess("newrepo2"));
+		assertFalse(team.hasUser("test"));
+		team = service.getTeamModel("nextteam");
+		assertTrue(team.hasUser("test"));
+
+		// delete the user and confirm team no longer has user
+		service.deleteUser("test");
+		team = service.getTeamModel("testteam");
+		assertFalse(team.hasUser("test"));
+
+		// delete both teams
+		service.deleteTeam("testteam");
+		service.deleteTeam("nextteam");
+		assertEquals(0, service.getAllTeamNames().size());
+	}
 }
\ No newline at end of file

--
Gitblit v1.9.1