James Moger
2011-12-07 fe24a0be919653d9e502f7729d9a804f2e28435d
Teams support.

Teams simplify the management of user-repository access permissions. Teams have a list of restricted repositories. Users are also added to teams and that grants them access to those repositories.

Federation and RPC support are still in-progress.
7 files added
18 files modified
1716 ■■■■ changed files
docs/01_features.mkd 1 ●●●● patch | view | raw | blame | history
docs/01_setup.mkd 177 ●●●● patch | view | raw | blame | history
docs/04_releases.mkd 1 ●●●● patch | view | raw | blame | history
resources/users_16x16.png patch | view | raw | blame | history
src/com/gitblit/ConfigUserService.java 307 ●●●●● patch | view | raw | blame | history
src/com/gitblit/FileUserService.java 317 ●●●●● patch | view | raw | blame | history
src/com/gitblit/GitBlit.java 79 ●●●●● patch | view | raw | blame | history
src/com/gitblit/IUserService.java 79 ●●●●● patch | view | raw | blame | history
src/com/gitblit/models/TeamModel.java 88 ●●●●● patch | view | raw | blame | history
src/com/gitblit/models/UserModel.java 25 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/DeepCopier.java 135 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/GitBlitWebApp.properties 6 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/WicketUtils.java 8 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditRepositoryPage.html 3 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditRepositoryPage.java 19 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditTeamPage.html 24 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditTeamPage.java 169 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditUserPage.html 5 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditUserPage.java 26 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/UsersPage.html 2 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/UsersPage.java 3 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/TeamsPanel.html 44 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/TeamsPanel.java 89 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/UsersPanel.html 2 ●●● patch | view | raw | blame | history
tests/com/gitblit/tests/UserServiceTest.java 107 ●●●●● patch | view | raw | blame | history
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)
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"]
@@ -168,13 +173,34 @@
        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.  
@@ -192,148 +218,7 @@
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%
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
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*   
resources/users_16x16.png
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);
            }
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
@@ -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(",");
                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;
    }
}
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);
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.
     * 
src/com/gitblit/models/TeamModel.java
New file
@@ -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);
    }
}
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;
src/com/gitblit/utils/DeepCopier.java
New file
@@ -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;
        }
    }
}
src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -188,3 +188,9 @@
gb.activeRepositories = active repositories
gb.activeAuthors = active authors
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
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", "");
    }
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>
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"));
src/com/gitblit/wicket/pages/EditTeamPage.html
New file
@@ -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>
src/com/gitblit/wicket/pages/EditTeamPage.java
New file
@@ -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);
    }
}
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>
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") {
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>
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));
    }
}
src/com/gitblit/wicket/panels/TeamsPanel.html
New file
@@ -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>
src/com/gitblit/wicket/panels/TeamsPanel.java
New file
@@ -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));
    }
}
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>
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());
    }
}