Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/sitewide/__init__.py b/sitewide/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/sitewide/__init__.py
@@ -0,0 +1 @@
+
diff --git a/sitewide/custom_404.py b/sitewide/custom_404.py
new file mode 100644
index 0000000..397bd1d
--- /dev/null
+++ b/sitewide/custom_404.py
@@ -0,0 +1,41 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Page class for generating somewhat informative project-page 404s.
+
+This page class produces a mostly-empty project subpage, which helps
+users find what they're looking for by providing navigational menus,
+rather than telling them "404. That's an error. That's all we know."
+which is maddeningly not helpful when we already have a project pb
+loaded.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+from framework import exceptions
+from framework import servlet
+
+
+class ErrorPage(servlet.Servlet):
+  """Page class for generating somewhat informative project-page 404s.
+
+  This page class produces a mostly-empty project subpage, which helps
+  users find what they're looking for by providing navigational menus,
+  rather than telling them "404. That's an error. That's all we know."
+  which is maddeningly not helpful when we already have a project pb
+  loaded.
+  """
+
+  _PAGE_TEMPLATE = 'sitewide/project-404-page.ezt'
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    if not mr.project_name:
+      raise exceptions.InputException('No project specified')
+    return {
+      'http_response_code': httplib.NOT_FOUND,
+      }
diff --git a/sitewide/group_helpers.py b/sitewide/group_helpers.py
new file mode 100644
index 0000000..b0195c5
--- /dev/null
+++ b/sitewide/group_helpers.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions used in user group modules."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from framework import framework_views
+from proto import usergroup_pb2
+
+
+class GroupVisibilityView(object):
+  """Object for group visibility information that can be easily used in EZT."""
+
+  VISIBILITY_NAMES = {
+      usergroup_pb2.MemberVisibility.ANYONE: 'Anyone on the Internet',
+      usergroup_pb2.MemberVisibility.MEMBERS: 'Group Members',
+      usergroup_pb2.MemberVisibility.OWNERS: 'Group Owners'}
+
+  def __init__(self, group_visibility_enum):
+    self.key = int(group_visibility_enum)
+    self.name = self.VISIBILITY_NAMES[group_visibility_enum]
+
+
+class GroupTypeView(object):
+  """Object for group type information that can be easily used in EZT."""
+
+  TYPE_NAMES = {
+      usergroup_pb2.GroupType.CHROME_INFRA_AUTH: 'Chrome-infra-auth',
+      usergroup_pb2.GroupType.MDB: 'MDB',
+      usergroup_pb2.GroupType.BAGGINS: 'Baggins',
+      usergroup_pb2.GroupType.COMPUTED: 'Computed',
+      }
+
+  def __init__(self, group_type_enum):
+    self.key = int(group_type_enum)
+    self.name = self.TYPE_NAMES[group_type_enum]
+
+
+class GroupMemberView(framework_views.UserView):
+  """Wrapper class to display basic group member information in a template."""
+
+  def __init__(self, user, group_id, role):
+    assert role in ['member', 'owner']
+    super(GroupMemberView, self).__init__(user)
+    self.group_id = group_id
+    self.role = role
+
+
+def BuildUserGroupVisibilityOptions():
+  """Return a list of user group visibility values for use in an HTML menu.
+
+  Returns:
+    A list of GroupVisibilityView objects that can be used in EZT.
+  """
+  vis_levels = [usergroup_pb2.MemberVisibility.OWNERS,
+                usergroup_pb2.MemberVisibility.MEMBERS,
+                usergroup_pb2.MemberVisibility.ANYONE]
+
+  return [GroupVisibilityView(vis) for vis in vis_levels]
+
+
+def BuildUserGroupTypeOptions():
+  """Return a list of user group types for use in an HTML menu.
+
+  Returns:
+    A list of GroupTypeView objects that can be used in EZT.
+  """
+  group_types = [usergroup_pb2.GroupType.CHROME_INFRA_AUTH,
+                 usergroup_pb2.GroupType.MDB,
+                 usergroup_pb2.GroupType.BAGGINS,
+                 usergroup_pb2.GroupType.COMPUTED]
+
+  return sorted([GroupTypeView(gt) for gt in group_types],
+                key=lambda gtv: gtv.name)
diff --git a/sitewide/groupadmin.py b/sitewide/groupadmin.py
new file mode 100644
index 0000000..32ba007
--- /dev/null
+++ b/sitewide/groupadmin.py
@@ -0,0 +1,123 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display user group admin page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import usergroup_pb2
+from services import usergroup_svc
+from sitewide import group_helpers
+
+
+class GroupAdmin(servlet.Servlet):
+  """The group admin page."""
+
+  _PAGE_TEMPLATE = 'sitewide/group-admin-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(GroupAdmin, self).AssertBasePermission(mr)
+
+    _, owner_ids_dict = self.services.usergroup.LookupMembers(
+        mr.cnxn, [mr.viewed_user_auth.user_id])
+    owner_ids = owner_ids_dict[mr.viewed_user_auth.user_id]
+    if not permissions.CanEditGroup(
+        mr.perms, mr.auth.effective_ids, owner_ids):
+      raise permissions.PermissionException(
+          'User is not allowed to edit a user group')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    group_id = mr.viewed_user_auth.user_id
+    group_settings = self.services.usergroup.GetGroupSettings(
+        mr.cnxn, group_id)
+    visibility_levels = group_helpers.BuildUserGroupVisibilityOptions()
+    initial_visibility = group_helpers.GroupVisibilityView(
+        group_settings.who_can_view_members)
+    group_types = group_helpers.BuildUserGroupTypeOptions()
+    import_group = bool(group_settings.ext_group_type)
+    if import_group:
+      initial_group_type = group_helpers.GroupTypeView(
+          group_settings.ext_group_type)
+    else:
+      initial_group_type = ''
+
+    if group_settings.friend_projects:
+      initial_friendprojects = ', '.join(
+          list(self.services.project.LookupProjectNames(
+              mr.cnxn, group_settings.friend_projects).values()))
+    else:
+      initial_friendprojects = ''
+
+    return {
+        'admin_tab_mode': 'st2',
+        'groupadmin': True,
+        'groupid': group_id,
+        'groupname': mr.viewed_username,
+        'group_types': group_types,
+        'import_group': import_group or '',
+        'initial_friendprojects': initial_friendprojects,
+        'initial_group_type': initial_group_type,
+        'initial_visibility': initial_visibility,
+        'offer_membership_editing': True,
+        'visibility_levels': visibility_levels,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    # 1. Gather data from the request.
+    group_name = mr.viewed_username
+    group_id = mr.viewed_user_auth.user_id
+
+    if post_data.get('import_group'):
+      vis_level = usergroup_pb2.MemberVisibility.OWNERS
+      ext_group_type = post_data.get('group_type')
+      friend_projects = ''
+      if not ext_group_type:
+        mr.errors.groupimport = 'Please provide external group type'
+      else:
+        ext_group_type = usergroup_pb2.GroupType(int(ext_group_type))
+    else:
+      vis_level = post_data.get('visibility')
+      ext_group_type = None
+      friend_projects = post_data.get('friendprojects', '')
+      if vis_level:
+        vis_level = usergroup_pb2.MemberVisibility(int(vis_level))
+      else:
+        mr.errors.groupimport = 'Cannot update settings for imported group'
+
+    if not mr.errors.AnyErrors():
+      project_ids, error = self.services.usergroup.ValidateFriendProjects(
+          mr.cnxn, self.services, friend_projects)
+      if error:
+        mr.errors.friendprojects = error
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      group_settings = usergroup_pb2.UserGroupSettings(
+        who_can_view_members=vis_level,
+        ext_group_type=ext_group_type,
+        friend_projects=project_ids)
+      self.services.usergroup.UpdateSettings(
+          mr.cnxn, group_id, group_settings)
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr, initial_name=group_name)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/g/%s%s' % (group_name, urls.GROUP_ADMIN),
+          include_project=False, saved=1, ts=int(time.time()))
diff --git a/sitewide/groupcreate.py b/sitewide/groupcreate.py
new file mode 100644
index 0000000..2dac146
--- /dev/null
+++ b/sitewide/groupcreate.py
@@ -0,0 +1,104 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A page for site admins to create a new user group."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from proto import usergroup_pb2
+from sitewide import group_helpers
+
+
+class GroupCreate(servlet.Servlet):
+  """Shows a page with a simple form to create a user group."""
+
+  _PAGE_TEMPLATE = 'sitewide/group-create-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(GroupCreate, self).AssertBasePermission(mr)
+
+    if not permissions.CanCreateGroup(mr.perms):
+      raise permissions.PermissionException(
+          'User is not allowed to create a user group')
+
+  def GatherPageData(self, _mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    visibility_levels = group_helpers.BuildUserGroupVisibilityOptions()
+    initial_visibility = group_helpers.GroupVisibilityView(
+        usergroup_pb2.MemberVisibility.ANYONE)
+    group_types = group_helpers.BuildUserGroupTypeOptions()
+
+    return {
+        'groupadmin': '',
+        'group_types': group_types,
+        'import_group': '',
+        'initial_friendprojects': '',
+        'initial_group_type': '',
+        'initial_name': '',
+        'initial_visibility': initial_visibility,
+        'visibility_levels': visibility_levels,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    # 1. Gather data from the request.
+    group_name = post_data.get('groupname')
+    try:
+      existing_group_id = self.services.user.LookupUserID(mr.cnxn, group_name)
+      existing_settings = self.services.usergroup.GetGroupSettings(
+          mr.cnxn, existing_group_id)
+      if existing_settings:
+        mr.errors.groupname = 'That user group already exists'
+    except exceptions.NoSuchUserException:
+      pass
+
+    if post_data.get('import_group'):
+      vis = usergroup_pb2.MemberVisibility.OWNERS
+      ext_group_type = post_data.get('group_type')
+      friend_projects = ''
+      if not ext_group_type:
+        mr.errors.groupimport = 'Please provide external group type'
+      else:
+        ext_group_type = str(
+            usergroup_pb2.GroupType(int(ext_group_type))).lower()
+
+      if (ext_group_type == 'computed' and
+          not group_name.startswith('everyone@')):
+        mr.errors.groupimport = 'Computed groups must be named everyone@'
+
+    else:
+      vis = usergroup_pb2.MemberVisibility(int(post_data['visibility']))
+      ext_group_type = None
+      friend_projects = post_data.get('friendprojects', '')
+    who_can_view_members = str(vis).lower()
+
+    if not mr.errors.AnyErrors():
+      project_ids, error = self.services.usergroup.ValidateFriendProjects(
+          mr.cnxn, self.services, friend_projects)
+      if error:
+        mr.errors.friendprojects = error
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      group_id = self.services.usergroup.CreateGroup(
+          mr.cnxn, self.services, group_name, who_can_view_members,
+          ext_group_type, project_ids)
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr, initial_name=group_name)
+    else:
+      # Go to the new user group's detail page.
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/g/%s/' % group_id, include_project=False)
diff --git a/sitewide/groupdetail.py b/sitewide/groupdetail.py
new file mode 100644
index 0000000..b28baa9
--- /dev/null
+++ b/sitewide/groupdetail.py
@@ -0,0 +1,210 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display a user group, including a paginated list of members."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import framework_views
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from project import project_helpers
+from proto import usergroup_pb2
+from sitewide import group_helpers
+from sitewide import sitewide_views
+
+MEMBERS_PER_PAGE = 50
+
+
+class GroupDetail(servlet.Servlet):
+  """The group detail page presents information about one user group."""
+
+  _PAGE_TEMPLATE = 'sitewide/group-detail-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(GroupDetail, self).AssertBasePermission(mr)
+
+    group_id = mr.viewed_user_auth.user_id
+    group_settings = self.services.usergroup.GetGroupSettings(
+        mr.cnxn, group_id)
+    if not group_settings:
+      return
+
+    member_ids, owner_ids = self.services.usergroup.LookupAllMembers(
+          mr.cnxn, [group_id])
+    (owned_project_ids, membered_project_ids,
+     contrib_project_ids) = self.services.project.GetUserRolesInAllProjects(
+         mr.cnxn, mr.auth.effective_ids)
+    project_ids = owned_project_ids.union(
+        membered_project_ids).union(contrib_project_ids)
+    if not permissions.CanViewGroupMembers(
+        mr.perms, mr.auth.effective_ids, group_settings, member_ids[group_id],
+        owner_ids[group_id], project_ids):
+      raise permissions.PermissionException(
+          'User is not allowed to view a user group')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    group_id = mr.viewed_user_auth.user_id
+    group_settings = self.services.usergroup.GetGroupSettings(
+        mr.cnxn, group_id)
+    if not group_settings:
+      raise exceptions.NoSuchGroupException()
+
+    member_ids_dict, owner_ids_dict = (
+        self.services.usergroup.LookupVisibleMembers(
+            mr.cnxn, [group_id], mr.perms, mr.auth.effective_ids,
+            self.services))
+    member_ids = member_ids_dict[group_id]
+    owner_ids = owner_ids_dict[group_id]
+    member_pbs_dict = self.services.user.GetUsersByIDs(
+        mr.cnxn, member_ids)
+    owner_pbs_dict = self.services.user.GetUsersByIDs(
+        mr.cnxn, owner_ids)
+    member_dict = {}
+    for user_id, user_pb in member_pbs_dict.items():
+      member_view = group_helpers.GroupMemberView(user_pb, group_id, 'member')
+      member_dict[user_id] = member_view
+    owner_dict = {}
+    for user_id, user_pb in owner_pbs_dict.items():
+      member_view = group_helpers.GroupMemberView(user_pb, group_id, 'owner')
+      owner_dict[user_id] = member_view
+
+    member_user_views = []
+    member_user_views.extend(
+        sorted(list(owner_dict.values()), key=lambda u: u.email))
+    member_user_views.extend(
+        sorted(list(member_dict.values()), key=lambda u: u.email))
+
+    group_view = sitewide_views.GroupView(
+        mr.viewed_user_auth.email, len(member_ids), group_settings,
+        mr.viewed_user_auth.user_id)
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    pagination = paginate.ArtifactPagination(
+        member_user_views, mr.GetPositiveIntParam('num', MEMBERS_PER_PAGE),
+        mr.GetPositiveIntParam('start'), mr.project_name, group_view.detail_url,
+        url_params=url_params)
+
+    is_imported_group = bool(group_settings.ext_group_type)
+
+    offer_membership_editing = permissions.CanEditGroup(
+        mr.perms, mr.auth.effective_ids, owner_ids) and not is_imported_group
+
+    group_type = 'Monorail user group'
+    if group_settings.ext_group_type:
+      group_type = str(group_settings.ext_group_type).capitalize()
+
+    return {
+        'admin_tab_mode': self.ADMIN_TAB_META,
+        'offer_membership_editing': ezt.boolean(offer_membership_editing),
+        'initial_add_members': '',
+        'initially_expand_form': ezt.boolean(False),
+        'groupid': group_id,
+        'groupname': mr.viewed_username,
+        'settings': group_settings,
+        'group_type': group_type,
+        'pagination': pagination,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    _, owner_ids_dict = self.services.usergroup.LookupMembers(
+        mr.cnxn, [mr.viewed_user_auth.user_id])
+    owner_ids = owner_ids_dict[mr.viewed_user_auth.user_id]
+    permit_edit = permissions.CanEditGroup(
+        mr.perms, mr.auth.effective_ids, owner_ids)
+    if not permit_edit:
+      raise permissions.PermissionException(
+          'User is not permitted to edit group membership')
+
+    group_settings = self.services.usergroup.GetGroupSettings(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+    if bool(group_settings.ext_group_type):
+      raise permissions.PermissionException(
+          'Imported groups are read-only')
+
+    if 'addbtn' in post_data:
+      return self.ProcessAddMembers(mr, post_data)
+    elif 'removebtn' in post_data:
+      return self.ProcessRemoveMembers(mr, post_data)
+
+  def ProcessAddMembers(self, mr, post_data):
+    """Process the user's request to add members.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      post_data: dictionary of form data.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    # 1. Gather data from the request.
+    group_id = mr.viewed_user_auth.user_id
+    add_members_str = post_data.get('addmembers')
+    new_member_ids = project_helpers.ParseUsernames(
+        mr.cnxn, self.services.user, add_members_str)
+    role = post_data['role']
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      try:
+        self.services.usergroup.UpdateMembers(
+            mr.cnxn, group_id, new_member_ids, role)
+      except exceptions.CircularGroupException:
+        mr.errors.addmembers = (
+            'The members are already ancestors of current group.')
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_add_members=add_members_str,
+          initially_expand_form=ezt.boolean(True))
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/g/%s/' % mr.viewed_username, include_project=False,
+          saved=1, ts=int(time.time()))
+
+  def ProcessRemoveMembers(self, mr, post_data):
+    """Process the user's request to remove members.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      post_data: dictionary of form data.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    # 1. Gather data from the request.
+    remove_strs = post_data.getall('remove')
+    logging.info('remove_strs = %r', remove_strs)
+
+    if not remove_strs:
+      mr.errors.remove = 'No users specified'
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      remove_ids = set(
+          self.services.user.LookupUserIDs(mr.cnxn, remove_strs).values())
+      self.services.usergroup.RemoveMembers(
+          mr.cnxn, mr.viewed_user_auth.user_id, remove_ids)
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/g/%s/' % mr.viewed_username, include_project=False,
+          saved=1, ts=int(time.time()))
diff --git a/sitewide/grouplist.py b/sitewide/grouplist.py
new file mode 100644
index 0000000..3adfaa3
--- /dev/null
+++ b/sitewide/grouplist.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes to list user groups."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from framework import xsrf
+from sitewide import sitewide_views
+
+
+class GroupList(servlet.Servlet):
+  """Shows a page with a simple form to create a user group."""
+
+  _PAGE_TEMPLATE = 'sitewide/group-list-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(GroupList, self).AssertBasePermission(mr)
+
+    if not mr.perms.HasPerm(permissions.VIEW_GROUP, None, None):
+      raise permissions.PermissionException(
+          'User is not allowed to view list of user groups')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    group_views = [
+        sitewide_views.GroupView(*groupinfo) for groupinfo in
+        self.services.usergroup.GetAllUserGroupsInfo(mr.cnxn)]
+    group_views.sort(key=lambda gv: gv.name)
+    offer_group_deletion = mr.perms.CanUsePerm(
+        permissions.DELETE_GROUP, mr.auth.effective_ids, None, [])
+    offer_group_creation = mr.perms.CanUsePerm(
+        permissions.CREATE_GROUP, mr.auth.effective_ids, None, [])
+
+    return {
+        'form_token': xsrf.GenerateToken(
+            mr.auth.user_id, '%s.do' % urls.GROUP_DELETE),
+        'groups': group_views,
+        'offer_group_deletion': ezt.boolean(offer_group_deletion),
+        'offer_group_creation': ezt.boolean(offer_group_creation),
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    if 'removebtn' in post_data:
+      return self.ProcessDeleteGroups(mr, post_data)
+
+  def ProcessDeleteGroups(self, mr, post_data):
+    """Process request to delete groups."""
+    if not mr.perms.CanUsePerm(
+        permissions.DELETE_GROUP, mr.auth.effective_ids, None, []):
+      raise permissions.PermissionException(
+          'User is not permitted to delete groups')
+
+    remove_groups = [int(g) for g in post_data.getall('remove')]
+
+    if not mr.errors.AnyErrors():
+      self.services.usergroup.DeleteGroups(mr.cnxn, remove_groups)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/g', include_project=False,
+          saved=1, ts=int(time.time()))
diff --git a/sitewide/hostinghome.py b/sitewide/hostinghome.py
new file mode 100644
index 0000000..4a0a47d
--- /dev/null
+++ b/sitewide/hostinghome.py
@@ -0,0 +1,107 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display the hosting home page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import ezt
+
+import settings
+from businesslogic import work_env
+from framework import exceptions
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+from project import project_views
+from sitewide import projectsearch
+from sitewide import sitewide_helpers
+
+
+class HostingHome(servlet.Servlet):
+  """HostingHome shows the project list and link to create a project."""
+
+  _PAGE_TEMPLATE = 'sitewide/hosting-home-page.ezt'
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    redirect_msg = self._MaybeRedirectToDomainDefaultProject(mr)
+    logging.info(redirect_msg)
+
+    can_create_project = permissions.CanCreateProject(mr.perms)
+
+    # Kick off the search pipeline, it has its own promises for parallelism.
+    pipeline = projectsearch.ProjectSearchPipeline(mr, self.services)
+
+    # Meanwhile, determine which projects the signed-in user has starred.
+    with work_env.WorkEnv(mr, self.services) as we:
+      starred_projects = we.ListStarredProjects()
+      starred_project_ids = {p.project_id for p in starred_projects}
+
+    # A dict of project id to the user's membership status.
+    project_memberships = {}
+    if mr.auth.user_id:
+      with work_env.WorkEnv(mr, self.services) as we:
+        owned, _archive_owned, member_of, contrib_of = (
+            we.GetUserProjects(mr.auth.effective_ids))
+      project_memberships.update({proj.project_id: 'Owner' for proj in owned})
+      project_memberships.update(
+          {proj.project_id: 'Member' for proj in member_of})
+      project_memberships.update(
+          {proj.project_id: 'Contributor' for proj in contrib_of})
+
+    # Finish the project search pipeline.
+    pipeline.SearchForIDs(domain=mr.request.host)
+    pipeline.GetProjectsAndPaginate(mr.cnxn, urls.HOSTING_HOME)
+    project_ids = [p.project_id for p in pipeline.visible_results]
+    star_count_dict = self.services.project_star.CountItemsStars(
+        mr.cnxn, project_ids)
+
+    # Make ProjectView objects
+    project_view_list = [
+        project_views.ProjectView(
+            p, starred=p.project_id in starred_project_ids,
+            num_stars=star_count_dict.get(p.project_id),
+            membership_desc=project_memberships.get(p.project_id))
+        for p in pipeline.visible_results]
+    return {
+        'can_create_project': ezt.boolean(can_create_project),
+        'learn_more_link': settings.learn_more_link,
+        'projects': project_view_list,
+        'pagination': pipeline.pagination,
+        }
+
+  def _MaybeRedirectToDomainDefaultProject(self, mr):
+    """If there is a relevant default project, redirect to it."""
+    project_name = settings.domain_to_default_project.get(mr.request.host)
+    if not project_name:
+      return 'No configured default project redirect for this domain.'
+
+    project = None
+    try:
+      project = self.services.project.GetProjectByName(mr.cnxn, project_name)
+    except exceptions.NoSuchProjectException:
+      pass
+
+    if not project:
+      return 'Domain default project %s not found' % project_name
+
+    if not permissions.UserCanViewProject(
+        mr.auth.user_pb, mr.auth.effective_ids, project):
+      return 'User cannot view default project: %r' % project
+
+    project_url = '/p/%s' % project_name
+    self.redirect(project_url, abort=True)
+    return 'Redirected to %r' % project_url
diff --git a/sitewide/moved.py b/sitewide/moved.py
new file mode 100644
index 0000000..3f63d24
--- /dev/null
+++ b/sitewide/moved.py
@@ -0,0 +1,62 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display a message explaining that a project has moved.
+
+When a project moves, we just display a link to the new location.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import servlet
+from framework import urls
+from project import project_constants
+
+
+class ProjectMoved(servlet.Servlet):
+  """The ProjectMoved page explains that the project has moved."""
+
+  _PAGE_TEMPLATE = 'sitewide/moved-page.ezt'
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    # We are not actually in /p/PROJECTNAME, so mr.project_name is None.
+    # Putting the ProjectMoved page inside a moved project would make
+    # the redirect logic much more complicated.
+    if not mr.specified_project:
+      raise exceptions.InputException('No project specified')
+
+    project = self.services.project.GetProjectByName(
+        mr.cnxn, mr.specified_project)
+    if not project:
+      self.abort(404, 'project not found')
+
+    if not project.moved_to:
+      # Only show this page for projects that are actually moved.
+      # Don't allow hackers to construct misleading links to this servlet.
+      logging.info('attempt to view ProjectMoved for non-moved project: %s',
+                   mr.specified_project)
+      self.abort(400, 'This project has not been moved')
+
+    if project_constants.RE_PROJECT_NAME.match(project.moved_to):
+      moved_to_url = framework_helpers.FormatAbsoluteURL(
+          mr, urls.SUMMARY, include_project=True, project_name=project.moved_to)
+    elif (project.moved_to.startswith('https://') or
+          project.moved_to.startswith('http://')):
+      moved_to_url = project.moved_to
+    else:
+      # Prevent users from using javascript: or any other tricky URL scheme.
+      moved_to_url = '#invalid-destination-url'
+
+    return {
+        'project_name': mr.specified_project,
+        'moved_to_url': moved_to_url,
+        }
diff --git a/sitewide/projectcreate.py b/sitewide/projectcreate.py
new file mode 100644
index 0000000..83862f6
--- /dev/null
+++ b/sitewide/projectcreate.py
@@ -0,0 +1,157 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes for users to create a new project."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+from six import string_types
+import ezt
+
+import settings
+from businesslogic import work_env
+from framework import exceptions
+from framework import filecontent
+from framework import framework_helpers
+from framework import gcs_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from project import project_constants
+from project import project_helpers
+from project import project_views
+from services import project_svc
+from tracker import tracker_bizobj
+from tracker import tracker_views
+
+
+_MSG_PROJECT_NAME_NOT_AVAIL = 'That project name is not available.'
+_MSG_MISSING_PROJECT_NAME = 'Missing project name'
+_MSG_INVALID_PROJECT_NAME = 'Invalid project name'
+_MSG_MISSING_PROJECT_SUMMARY = 'Missing project summary'
+
+
+class ProjectCreate(servlet.Servlet):
+  """Shows a page with a simple form to create a project."""
+
+  _PAGE_TEMPLATE = 'sitewide/project-create-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(ProjectCreate, self).AssertBasePermission(mr)
+
+    if not permissions.CanCreateProject(mr.perms):
+      raise permissions.PermissionException(
+          'User is not allowed to create a project')
+
+  def GatherPageData(self, _mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    available_access_levels = project_helpers.BuildProjectAccessOptions(None)
+    offer_access_level = len(available_access_levels) > 1
+    if settings.default_access_level:
+      access_view = project_views.ProjectAccessView(
+          settings.default_access_level)
+    else:
+      access_view = None
+
+    return {
+        'initial_name': '',
+        'initial_summary': '',
+        'initial_description': '',
+        'initial_project_home': '',
+        'initial_docs_url': '',
+        'initial_source_url': '',
+        'initial_logo_gcs_id': '',
+        'initial_logo_file_name': '',
+        'logo_view': tracker_views.LogoView(None),
+        'labels': [],
+        'max_project_name_length': project_constants.MAX_PROJECT_NAME_LENGTH,
+        'offer_access_level': ezt.boolean(offer_access_level),
+        'initial_access': access_view,
+        'available_access_levels': available_access_levels,
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    # 1. Parse and validate user input.
+    # Project name is taken from post_data because we are creating it.
+    project_name = post_data.get('projectname')
+    if not project_name:
+      mr.errors.projectname = _MSG_MISSING_PROJECT_NAME
+    elif not project_helpers.IsValidProjectName(project_name):
+      mr.errors.projectname = _MSG_INVALID_PROJECT_NAME
+
+    summary = post_data.get('summary')
+    if not summary:
+      mr.errors.summary = _MSG_MISSING_PROJECT_SUMMARY
+    description = post_data.get('description', '')
+
+    access = project_helpers.ParseProjectAccess(None, post_data.get('access'))
+    home_page = post_data.get('project_home')
+    if home_page and not (
+        home_page.startswith('http://') or home_page.startswith('https://')):
+      mr.errors.project_home = 'Home page link must start with http(s)://'
+    docs_url = post_data.get('docs_url')
+    if docs_url and not (
+        docs_url.startswith('http:') or docs_url.startswith('https:')):
+      mr.errors.docs_url = 'Documentation link must start with http: or https:'
+
+    # These are not specified on via the ProjectCreate form,
+    # the user must edit the project after creation to set them.
+    committer_ids = []
+    contributor_ids = []
+
+    # Validate that provided logo is supported.
+    logo_provided = 'logo' in post_data and not isinstance(
+        post_data['logo'], string_types)
+    if logo_provided:
+      item = post_data['logo']
+      try:
+        gcs_helpers.CheckMimeTypeResizable(
+            filecontent.GuessContentTypeFromFilename(item.filename))
+      except gcs_helpers.UnsupportedMimeType, e:
+        mr.errors.logo = e.message
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      with work_env.WorkEnv(mr, self.services) as we:
+        try:
+          project_id = we.CreateProject(
+              project_name, [mr.auth.user_id],
+              committer_ids, contributor_ids, summary, description,
+              access=access, home_page=home_page, docs_url=docs_url)
+
+          config = tracker_bizobj.MakeDefaultProjectIssueConfig(project_id)
+          self.services.config.StoreConfig(mr.cnxn, config)
+          # Note: No need to store any canned queries or rules yet.
+          self.services.issue.InitializeLocalID(mr.cnxn, project_id)
+
+          # Update project with  logo if specified.
+          if logo_provided:
+            item = post_data['logo']
+            logo_file_name = item.filename
+            logo_gcs_id = gcs_helpers.StoreLogoInGCS(
+                logo_file_name, item.value, project_id)
+            we.UpdateProject(
+                project_id, logo_gcs_id=logo_gcs_id,
+                logo_file_name=logo_file_name)
+
+        except exceptions.ProjectAlreadyExists:
+          mr.errors.projectname = _MSG_PROJECT_NAME_NOT_AVAIL
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      access_view = project_views.ProjectAccessView(access)
+      self.PleaseCorrect(
+          mr, initial_summary=summary, initial_description=description,
+          initial_name=project_name, initial_access=access_view)
+    else:
+      # Go to the new project's introduction page.
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_INTRO, project_name=project_name)
diff --git a/sitewide/projectsearch.py b/sitewide/projectsearch.py
new file mode 100644
index 0000000..8ef5fee
--- /dev/null
+++ b/sitewide/projectsearch.py
@@ -0,0 +1,63 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used when searching for projects."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from businesslogic import work_env
+from framework import framework_helpers
+from framework import paginate
+from framework import permissions
+
+
+DEFAULT_RESULTS_PER_PAGE = 100
+
+
+class ProjectSearchPipeline(object):
+  """Manage the process of project search, filter, fetch, and pagination."""
+
+  def __init__(self, mr, services,
+               default_results_per_page=DEFAULT_RESULTS_PER_PAGE):
+
+    self.mr = mr
+    self.services = services
+    self.default_results_per_page = default_results_per_page
+    self.pagination = None
+    self.allowed_project_ids = None
+    self.visible_results = None
+
+  def SearchForIDs(self, domain=None):
+    """Get project IDs the user has permission to view."""
+    with work_env.WorkEnv(self.mr, self.services) as we:
+      self.allowed_project_ids = we.ListProjects(domain=domain)
+      logging.info('allowed_project_ids is %r', self.allowed_project_ids)
+
+  def GetProjectsAndPaginate(self, cnxn, list_page_url):
+    """Paginate the filtered list of project names and retrieve Project PBs.
+
+    Args:
+      cnxn: connection to SQL database.
+      list_page_url: string page URL for prev and next links.
+    """
+    with self.mr.profiler.Phase('getting all projects'):
+      project_dict = self.services.project.GetProjects(
+          cnxn, self.allowed_project_ids)
+      project_list = sorted(
+          project_dict.values(),
+          key=lambda p: p.project_name)
+      logging.info('project_list is %r', project_list)
+
+    url_params = [(name, self.mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    self.pagination = paginate.ArtifactPagination(
+        project_list,
+        self.mr.GetPositiveIntParam('num', self.default_results_per_page),
+        self.mr.GetPositiveIntParam('start'), self.mr.project_name,
+        list_page_url, url_params=url_params)
+    self.visible_results = self.pagination.visible_results
diff --git a/sitewide/sitewide_helpers.py b/sitewide/sitewide_helpers.py
new file mode 100644
index 0000000..33f53c3
--- /dev/null
+++ b/sitewide/sitewide_helpers.py
@@ -0,0 +1,38 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions used in sitewide servlets."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import permissions
+from proto import project_pb2
+
+
+def GetViewableStarredProjects(
+    cnxn, services, viewed_user_id, effective_ids, logged_in_user):
+  """Returns a list of viewable starred projects."""
+  starred_project_ids = services.project_star.LookupStarredItemIDs(
+      cnxn, viewed_user_id)
+  projects = list(
+      services.project.GetProjects(cnxn, starred_project_ids).values())
+  viewable_projects = FilterViewableProjects(
+      projects, logged_in_user, effective_ids)
+  return viewable_projects
+
+
+def FilterViewableProjects(project_list, logged_in_user, effective_ids):
+  """Return subset of LIVE project protobufs viewable by the given user."""
+  viewable_projects = []
+  for project in project_list:
+    if (project.state == project_pb2.ProjectState.LIVE and
+        permissions.UserCanViewProject(
+            logged_in_user, effective_ids, project)):
+      viewable_projects.append(project)
+
+  return viewable_projects
diff --git a/sitewide/sitewide_views.py b/sitewide/sitewide_views.py
new file mode 100644
index 0000000..64b33bd
--- /dev/null
+++ b/sitewide/sitewide_views.py
@@ -0,0 +1,23 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""View objects to help display users and groups in UI templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+
+class GroupView(object):
+  """Class to make it easier to display user group metadata."""
+
+  def __init__(self, name, num_members, group_settings, group_id):
+    self.name = name
+    self.num_members = num_members
+    self.who_can_view_members = str(group_settings.who_can_view_members)
+    self.group_id = group_id
+
+    self.detail_url = '/g/%s/' % group_id
diff --git a/sitewide/test/__init__.py b/sitewide/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/sitewide/test/__init__.py
diff --git a/sitewide/test/custom_404_test.py b/sitewide/test/custom_404_test.py
new file mode 100644
index 0000000..71b52f8
--- /dev/null
+++ b/sitewide/test/custom_404_test.py
@@ -0,0 +1,44 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the custom_404 servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import unittest
+
+from framework import exceptions
+from services import service_manager
+from sitewide import custom_404
+from testing import fake
+from testing import testing_helpers
+
+
+class Custom404Test(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService())
+    self.servlet = custom_404.ErrorPage('req', 'res', services=self.services)
+
+  def testGatherPageData_NoProjectSpecified(self):
+    """Project was not included in URL, so raise exception, will cause 400."""
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/not/a/project/url')
+
+    with self.assertRaises(exceptions.InputException):
+      self.servlet.GatherPageData(mr)
+
+  def testGatherPageData_Normal(self):
+    """Return page_data dict with a 404 response code specified."""
+    _project = self.services.project.TestAddProject('proj')
+    _, mr = testing_helpers.GetRequestObjects(path='/p/proj/junk')
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+      {'http_response_code': httplib.NOT_FOUND},
+      page_data)
diff --git a/sitewide/test/group_helpers_test.py b/sitewide/test/group_helpers_test.py
new file mode 100644
index 0000000..af03d08
--- /dev/null
+++ b/sitewide/test/group_helpers_test.py
@@ -0,0 +1,51 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for User Group helpers."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import user_pb2
+from proto import usergroup_pb2
+from sitewide import group_helpers
+
+
+class GroupHelpersTest(unittest.TestCase):
+
+  def testGroupVisibilityView(self):
+    gvv_anyone = group_helpers.GroupVisibilityView(
+        usergroup_pb2.MemberVisibility.ANYONE)
+    gvv_members = group_helpers.GroupVisibilityView(
+        usergroup_pb2.MemberVisibility.MEMBERS)
+    gvv_owners = group_helpers.GroupVisibilityView(
+        usergroup_pb2.MemberVisibility.OWNERS)
+    self.assertEqual('Anyone on the Internet', gvv_anyone.name)
+    self.assertEqual('Group Members', gvv_members.name)
+    self.assertEqual('Group Owners', gvv_owners.name)
+
+  def testGroupMemberView(self):
+    user = user_pb2.MakeUser(1, email='test@example.com')
+    gmv = group_helpers.GroupMemberView(user, 888, 'member')
+    self.assertEqual(888, gmv.group_id)
+    self.assertEqual('member', gmv.role)
+
+  def testBuildUserGroupVisibilityOptions(self):
+    vis_views = group_helpers.BuildUserGroupVisibilityOptions()
+    self.assertEqual(3, len(vis_views))
+
+  def testGroupTypeView(self):
+    gt_cia = group_helpers.GroupTypeView(
+        usergroup_pb2.GroupType.CHROME_INFRA_AUTH)
+    gt_mdb = group_helpers.GroupTypeView(
+        usergroup_pb2.GroupType.MDB)
+    self.assertEqual('Chrome-infra-auth', gt_cia.name)
+    self.assertEqual('MDB', gt_mdb.name)
+
+  def testBuildUserGroupTypeOptions(self):
+    group_types = group_helpers.BuildUserGroupTypeOptions()
+    self.assertEqual(4, len(group_types))
diff --git a/sitewide/test/groupadmin_test.py b/sitewide/test/groupadmin_test.py
new file mode 100644
index 0000000..d1f7e0f
--- /dev/null
+++ b/sitewide/test/groupadmin_test.py
@@ -0,0 +1,85 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for User Group admin servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from proto import usergroup_pb2
+from services import service_manager
+from sitewide import groupadmin
+from testing import fake
+from testing import testing_helpers
+
+
+class GrouAdminTest(unittest.TestCase):
+  """Tests for the GroupAdmin servlet."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.services.user.TestAddUser('group@example.com', 888)
+    self.services.user.TestAddUser('importgroup@example.com', 999)
+    self.services.usergroup.TestAddGroupSettings(888, 'group@example.com')
+    self.services.usergroup.TestAddGroupSettings(
+        999, 'importgroup@example.com', external_group_type='mdb')
+    self.servlet = groupadmin.GroupAdmin(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.mr.viewed_username = 'group@example.com'
+    self.mr.viewed_user_auth.user_id = 888
+
+  def testAssertBasePermission(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 888
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+    self.services.usergroup.TestAddMembers(888, [111], 'owner')
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPageData_Normal(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('group@example.com', page_data['groupname'])
+    self.assertEqual('Group Members', page_data['initial_visibility'].name)
+    self.assertEqual(3, len(page_data['visibility_levels']))
+
+  def testGatherPageData_Import(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.viewed_username = 'importgroup@example.com'
+    mr.viewed_user_auth.user_id = 999
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('importgroup@example.com', page_data['groupname'])
+    self.assertTrue(page_data['import_group'])
+    self.assertEqual('MDB', page_data['initial_group_type'].name)
+
+  def testProcessFormData_Normal(self):
+    post_data = fake.PostData(visibility='0')
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIn('/g/group@example.com/groupadmin', url)
+    group_settings = self.services.usergroup.GetGroupSettings(None, 888)
+    self.assertEqual(usergroup_pb2.MemberVisibility.OWNERS,
+                     group_settings.who_can_view_members)
+
+  def testProcessFormData_Import(self):
+    post_data = fake.PostData(
+        group_type='1', import_group=['on'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIn('/g/group@example.com/groupadmin', url)
+    group_settings = self.services.usergroup.GetGroupSettings(None, 888)
+    self.assertEqual(usergroup_pb2.MemberVisibility.OWNERS,
+                     group_settings.who_can_view_members)
+    self.assertEqual(usergroup_pb2.GroupType.MDB,
+                     group_settings.ext_group_type)
diff --git a/sitewide/test/groupcreate_test.py b/sitewide/test/groupcreate_test.py
new file mode 100644
index 0000000..bf7be8d
--- /dev/null
+++ b/sitewide/test/groupcreate_test.py
@@ -0,0 +1,101 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for User Group creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import settings
+from framework import permissions
+from proto import site_pb2
+from proto import usergroup_pb2
+from services import service_manager
+from sitewide import groupcreate
+from testing import fake
+from testing import testing_helpers
+
+
+class GroupCreateTest(unittest.TestCase):
+  """Tests for the GroupCreate servlet."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+    self.servlet = groupcreate.GroupCreate(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest()
+
+  def CheckAssertBasePermissions(
+      self, restriction, expect_admin_ok, expect_nonadmin_ok):
+    old_group_creation_restriction = settings.group_creation_restriction
+    settings.group_creation_restriction = restriction
+
+    # Anon users can never do it
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if expect_admin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(mr.auth.user_pb, {111}, None))
+    if expect_nonadmin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    settings.group_creation_restriction = old_group_creation_restriction
+
+  def testAssertBasePermission(self):
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ANYONE, True, True)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ADMIN_ONLY, True, False)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.NO_ONE, False, False)
+
+  def testGatherPageData(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('', page_data['initial_name'])
+
+  def testProcessFormData_Normal(self):
+    post_data = fake.PostData(
+        groupname=['group@example.com'], visibility='1')
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIn('/g/3444127190/', url)
+    group_id = self.services.user.LookupUserID('cnxn', 'group@example.com')
+    group_settings = self.services.usergroup.GetGroupSettings('cnxn', group_id)
+    self.assertIsNotNone(group_settings)
+    members_after, owners_after = self.services.usergroup.LookupMembers(
+        'cnxn', [group_id])
+    self.assertEqual(0, len(members_after[group_id] + owners_after[group_id]))
+
+  def testProcessFormData_Import(self):
+    post_data = fake.PostData(
+        groupname=['group@example.com'], group_type='1',
+        import_group=['on'])
+    self.servlet.ProcessFormData(self.mr, post_data)
+    group_id = self.services.user.LookupUserID('cnxn', 'group@example.com')
+    group_settings = self.services.usergroup.GetGroupSettings('cnxn', group_id)
+    self.assertIsNotNone(group_settings)
+    self.assertEqual(usergroup_pb2.MemberVisibility.OWNERS,
+                     group_settings.who_can_view_members)
+    self.assertEqual(usergroup_pb2.GroupType.MDB,
+                     group_settings.ext_group_type)
diff --git a/sitewide/test/groupdetail_test.py b/sitewide/test/groupdetail_test.py
new file mode 100644
index 0000000..4440bb8
--- /dev/null
+++ b/sitewide/test/groupdetail_test.py
@@ -0,0 +1,146 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for User Group Detail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import exceptions
+from framework import permissions
+from services import service_manager
+from sitewide import groupdetail
+from testing import fake
+from testing import testing_helpers
+
+
+class GroupDetailTest(unittest.TestCase):
+  """Tests for the GroupDetail servlet."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.services.user.TestAddUser('group@example.com', 888)
+    self.services.usergroup.TestAddGroupSettings(888, 'group@example.com')
+    self.servlet = groupdetail.GroupDetail(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.mr.viewed_username = 'group@example.com'
+    self.mr.viewed_user_auth.user_id = 888
+
+  def testAssertBasePermission(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 888
+    mr.auth.effective_ids = set([111])
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+    self.services.usergroup.TestAddMembers(888, [111], 'member')
+    self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_IgnoreNoSuchGroup(self):
+    """The permission check does not crash for non-existent user groups."""
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 404
+    mr.auth.effective_ids = set([111])
+    self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_IndirectMembership(self):
+    self.services.usergroup.TestAddGroupSettings(999, 'subgroup@example.com')
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 888
+    mr.auth.effective_ids = set([111])
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+    self.services.usergroup.TestAddMembers(888, [999], 'member')
+    self.services.usergroup.TestAddMembers(999, [111], 'member')
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPagData_ZeroMembers(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    pagination = page_data['pagination']
+    self.assertEqual(0, len(pagination.visible_results))
+
+  def testGatherPagData_NonzeroMembers(self):
+    self.services.usergroup.TestAddMembers(888, [111, 222, 333])
+    page_data = self.servlet.GatherPageData(self.mr)
+    pagination = page_data['pagination']
+    self.assertEqual(3, len(pagination.visible_results))
+    self.assertEqual(3, pagination.total_count)
+    self.assertEqual(1, pagination.start)
+    self.assertEqual(3, pagination.last)
+    user_view_a, user_view_b, user_view_c = pagination.visible_results
+    self.assertEqual('a@example.com', user_view_a.email)
+    self.assertEqual('b@example.com', user_view_b.email)
+    self.assertEqual('c@example.com', user_view_c.email)
+
+  def testProcessAddMembers_NoneAdded(self):
+    post_data = fake.PostData(addmembers=[''], role=['member'])
+    url = self.servlet.ProcessAddMembers(self.mr, post_data)
+    self.assertIn('/g/group@example.com/?', url)
+    members_after, _ = self.services.usergroup.LookupMembers('cnxn', [888])
+    self.assertEqual(0, len(members_after[888]))
+
+    self.services.usergroup.TestAddMembers(888, [111, 222, 333])
+    url = self.servlet.ProcessAddMembers(self.mr, post_data)
+    self.assertIn('/g/group@example.com/?', url)
+    members_after, _ = self.services.usergroup.LookupMembers('cnxn', [888])
+    self.assertEqual(3, len(members_after[888]))
+
+  def testProcessAddMembers_SomeAdded(self):
+    self.services.usergroup.TestAddMembers(888, [111])
+    post_data = fake.PostData(
+        addmembers=['b@example.com, c@example.com'], role=['member'])
+    url = self.servlet.ProcessAddMembers(self.mr, post_data)
+    self.assertIn('/g/group@example.com/?', url)
+    members_after, _ = self.services.usergroup.LookupMembers('cnxn', [888])
+    self.assertEqual(3, len(members_after[888]))
+
+  def testProcessRemoveMembers_SomeRemoved(self):
+    self.services.usergroup.TestAddMembers(888, [111, 222, 333])
+    post_data = fake.PostData(remove=['b@example.com', 'c@example.com'])
+    url = self.servlet.ProcessRemoveMembers(self.mr, post_data)
+    self.assertIn('/g/group@example.com/?', url)
+    members_after, _ = self.services.usergroup.LookupMembers('cnxn', [888])
+    self.assertEqual(1, len(members_after[888]))
+
+  def testProcessFormData_NoPermission(self):
+    """Group members cannot edit group."""
+    self.services.usergroup.TestAddMembers(888, [111], 'member')
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 888
+    mr.auth.effective_ids = set([111])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, {})
+
+  def testProcessFormData_OwnerPermission(self):
+    """Group owners cannot edit group."""
+    self.services.usergroup.TestAddMembers(888, [111], 'owner')
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_id = 888
+    mr.auth.effective_ids = set([111])
+    self.servlet.ProcessFormData(mr, {})
+
+  def testGatherPagData_NoSuchUserGroup(self):
+    """If there is no such user group, raise an exception."""
+    self.mr.viewed_user_auth.user_id = 404
+    self.assertRaises(
+        exceptions.NoSuchGroupException,
+        self.servlet.GatherPageData, self.mr)
+
+
diff --git a/sitewide/test/grouplist_test.py b/sitewide/test/grouplist_test.py
new file mode 100644
index 0000000..9ec6bd5
--- /dev/null
+++ b/sitewide/test/grouplist_test.py
@@ -0,0 +1,84 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for User Group List servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.ext import testbed
+
+from framework import permissions
+from services import service_manager
+from sitewide import grouplist
+from testing import fake
+from testing import testing_helpers
+
+
+class GroupListTest(unittest.TestCase):
+  """Tests for the GroupList servlet."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        usergroup=fake.UserGroupService())
+    self.servlet = grouplist.GroupList('req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testAssertBasePermission_Anon(self):
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_RegularUsers(self):
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_SiteAdmin(self):
+    self.mr.perms = permissions.ADMIN_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPagData_ZeroGroups(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual([], page_data['groups'])
+
+  def testGatherPagData_NonzeroGroups(self):
+    self.services.usergroup.TestAddGroupSettings(777, 'group_a@example.com')
+    self.services.usergroup.TestAddGroupSettings(888, 'group_b@example.com')
+    self.services.usergroup.TestAddMembers(888, [111, 222, 333])
+    page_data = self.servlet.GatherPageData(self.mr)
+    group_view_a, group_view_b = page_data['groups']
+    self.assertEqual('group_a@example.com', group_view_a.name)
+    self.assertEqual(0, group_view_a.num_members)
+    self.assertEqual('group_b@example.com', group_view_b.name)
+    self.assertEqual(3, group_view_b.num_members)
+
+  def testProcessFormData_NoPermission(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.USER_PERMISSIONSET)
+    post_data = fake.PostData(
+      removebtn=[1])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, post_data)
+
+  def testProcessFormData_Normal(self):
+    self.services.usergroup.TestAddGroupSettings(
+        888, 'group_b@example.com', friend_projects=[789])
+    self.services.usergroup.TestAddMembers(888, [111, 222, 333])
+
+    post_data = fake.PostData(
+        remove=[888],
+        removebtn=[1])
+    self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertNotIn(888, self.services.usergroup.group_settings)
diff --git a/sitewide/test/hostinghome_test.py b/sitewide/test/hostinghome_test.py
new file mode 100644
index 0000000..f51c9ec
--- /dev/null
+++ b/sitewide/test/hostinghome_test.py
@@ -0,0 +1,146 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the Monorail home page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import ezt
+
+import settings
+from framework import permissions
+from proto import project_pb2
+from proto import site_pb2
+from services import service_manager
+from sitewide import hostinghome
+from sitewide import projectsearch
+from testing import fake
+from testing import testing_helpers
+
+
+class MockProjectSearchPipeline(object):
+
+  def __init__(self, _mr, services):
+    self.visible_results = services.mock_visible_results
+    self.pagination = None
+
+  def SearchForIDs(self, domain=None):
+    pass
+
+  def GetProjectsAndPaginate(self, cnxn, list_page_url):
+    pass
+
+
+class HostingHomeTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        project_star=fake.ProjectStarService())
+    self.services.mock_visible_results = []
+    self.project_a = self.services.project.TestAddProject('a', project_id=1)
+    self.project_b = self.services.project.TestAddProject('b', project_id=2)
+
+    self.servlet = hostinghome.HostingHome('req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+
+    self.orig_pipeline_class = projectsearch.ProjectSearchPipeline
+    projectsearch.ProjectSearchPipeline = MockProjectSearchPipeline
+
+  def tearDown(self):
+    projectsearch.ProjectSearchPipeline = self.orig_pipeline_class
+
+  def testSearch_ZeroResults(self):
+    self.services.mock_visible_results = []
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual([], page_data['projects'])
+
+  def testSearch_NonzeroResults(self):
+    self.services.mock_visible_results = [self.project_a, self.project_b]
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(['a', 'b'],
+                     [pv.project_name for pv in page_data['projects']])
+
+  def testStarCounts(self):
+    """Test the display of star counts on each displayed project."""
+    self.services.mock_visible_results = [self.project_a, self.project_b]
+    # We go straight to the services layer because this is a test set up
+    # rather than an actual user request.
+    self.services.project_star.SetStar('fake cnxn', 1, 111, True)
+    self.services.project_star.SetStar('fake cnxn', 1, 222, True)
+    page_data = self.servlet.GatherPageData(self.mr)
+    project_view_a, project_view_b = page_data['projects']
+    self.assertEqual(2, project_view_a.num_stars)
+    self.assertEqual(0, project_view_b.num_stars)
+
+  def testStarredProjects(self):
+    self.services.mock_visible_results = [self.project_a, self.project_b]
+    self.services.project_star.SetStar('fake cnxn', 1, 111, True)
+    page_data = self.servlet.GatherPageData(self.mr)
+    project_view_a, project_view_b = page_data['projects']
+    self.assertTrue(project_view_a.starred)
+    self.assertFalse(project_view_b.starred)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(settings.learn_more_link, page_data['learn_more_link'])
+
+  def testGatherPageData_CanCreateProject(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.perms = permissions.PermissionSet([permissions.CREATE_PROJECT])
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+      ezt.boolean(settings.project_creation_restriction ==
+                  site_pb2.UserTypeRestriction.ANYONE),
+      page_data['can_create_project'])
+
+    mr.perms = permissions.PermissionSet([])
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(False), page_data['can_create_project'])
+
+  @mock.patch('settings.domain_to_default_project', {})
+  def testMaybeRedirectToDomainDefaultProject_NoMatch(self):
+    """No redirect if the user is not accessing via a configured domain."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('No configured'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'huh'})
+  def testMaybeRedirectToDomainDefaultProject_NoSuchProject(self):
+    """No redirect if the configured project does not exist."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    print('host is %r' % mr.request.host)
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.endswith('not found'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_CantView(self):
+    """No redirect if the user can't view the configured project."""
+    self.project_a.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('User cannot'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_Redirect(self):
+    """We redirect if there's a configured project that the user can view."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    self.servlet.redirect = mock.Mock()
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('Redirected'))
+    self.servlet.redirect.assert_called_once()
diff --git a/sitewide/test/moved_test.py b/sitewide/test/moved_test.py
new file mode 100644
index 0000000..04b9165
--- /dev/null
+++ b/sitewide/test/moved_test.py
@@ -0,0 +1,113 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the moved project notification page servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import webapp2
+
+from framework import exceptions
+from services import service_manager
+from sitewide import moved
+from testing import fake
+from testing import testing_helpers
+
+
+class MovedTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService())
+    self.servlet = moved.ProjectMoved('req', 'res', services=self.services)
+    self.old_project = 'old-project'
+
+  def testGatherPageData_NoProjectSpecified(self):
+    # Project was not included in URL, so raise exception, will cause 400.
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved')
+
+    with self.assertRaises(exceptions.InputException):
+      self.servlet.GatherPageData(mr)
+
+  def testGatherPageData_NoSuchProject(self):
+    # Project doesn't exist, so 404 NOT FOUND.
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=nonexistent')
+
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGatherPageData_NotMoved(self):
+    # Project exists but has not been moved, so 400 BAD_REQUEST.
+    self.services.project.TestAddProject(self.old_project)
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=%s' % self.old_project)
+
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(400, cm.exception.code)
+
+  def testGatherPageData_URL(self):
+    # Display the moved_to url if it is valid.
+    project = self.services.project.TestAddProject(self.old_project)
+    project.moved_to = 'https://other-tracker.bugs'
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=%s' % self.old_project)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertItemsEqual(
+        ['project_name', 'moved_to_url'],
+        list(page_data.keys()))
+    self.assertEqual(self.old_project, page_data['project_name'])
+    self.assertEqual('https://other-tracker.bugs', page_data['moved_to_url'])
+
+  def testGatherPageData_ProjectName(self):
+    # Construct the moved-to url from just the project name.
+    project = self.services.project.TestAddProject(self.old_project)
+    project.moved_to = 'new-project'
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=%s' % self.old_project)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertItemsEqual(
+        ['project_name', 'moved_to_url'],
+        list(page_data.keys()))
+    self.assertEqual(self.old_project, page_data['project_name'])
+    self.assertEqual('http://127.0.0.1/p/new-project/',
+                     page_data['moved_to_url'])
+
+  def testGatherPageData_HttpProjectName(self):
+    # A project named "http-foo" gets treated as a project, not a url.
+    project = self.services.project.TestAddProject(self.old_project)
+    project.moved_to = 'http-project'
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=%s' % self.old_project)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertItemsEqual(
+        ['project_name', 'moved_to_url'],
+        list(page_data.keys()))
+    self.assertEqual(self.old_project, page_data['project_name'])
+    self.assertEqual('http://127.0.0.1/p/http-project/',
+                     page_data['moved_to_url'])
+
+  def testGatherPageData_BadScheme(self):
+    # We only display URLs that start with 'http(s)://'.
+    project = self.services.project.TestAddProject(self.old_project)
+    project.moved_to = 'javascript:alert(1)'
+    _, mr = testing_helpers.GetRequestObjects(
+        path='/hosting/moved?project=%s' % self.old_project)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertItemsEqual(
+        ['project_name', 'moved_to_url'],
+        list(page_data.keys()))
+    self.assertEqual(self.old_project, page_data['project_name'])
+    self.assertEqual('#invalid-destination-url', page_data['moved_to_url'])
diff --git a/sitewide/test/projectcreate_test.py b/sitewide/test/projectcreate_test.py
new file mode 100644
index 0000000..8f468dd
--- /dev/null
+++ b/sitewide/test/projectcreate_test.py
@@ -0,0 +1,74 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the Project Creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import settings
+from framework import permissions
+from proto import project_pb2
+from proto import site_pb2
+from services import service_manager
+from sitewide import projectcreate
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectCreateTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services()
+    self.servlet = projectcreate.ProjectCreate('req', 'res', services=services)
+
+  def CheckAssertBasePermissions(
+      self, restriction, expect_admin_ok, expect_nonadmin_ok):
+    old_project_creation_restriction = settings.project_creation_restriction
+    settings.project_creation_restriction = restriction
+
+    # Anon users can never do it
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if expect_admin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(mr.auth.user_pb, {111}, None))
+    if expect_nonadmin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    settings.project_creation_restriction = old_project_creation_restriction
+
+  def testAssertBasePermission(self):
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ANYONE, True, True)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ADMIN_ONLY, True, False)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.NO_ONE, False, False)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('', page_data['initial_name'])
+    self.assertEqual('', page_data['initial_summary'])
+    self.assertEqual('', page_data['initial_description'])
+    self.assertEqual([], page_data['labels'])
diff --git a/sitewide/test/projectsearch_test.py b/sitewide/test/projectsearch_test.py
new file mode 100644
index 0000000..a0d941d
--- /dev/null
+++ b/sitewide/test/projectsearch_test.py
@@ -0,0 +1,75 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the projectsearch module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+from framework import profiler
+from proto import project_pb2
+from services import service_manager
+from sitewide import projectsearch
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectSearchTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService())
+    self.services.project.GetVisibleLiveProjects = mock.MagicMock()
+
+    for idx, letter in enumerate('abcdefghijklmnopqrstuvwxyz'):
+      self.services.project.TestAddProject(letter, project_id=idx + 1)
+    for idx in range(27, 110):
+      self.services.project.TestAddProject(str(idx), project_id=idx)
+
+    self.addCleanup(mock.patch.stopall())
+
+  def TestPipeline(self, expected_last, expected_len):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.can = 1
+
+    pipeline = projectsearch.ProjectSearchPipeline(mr, self.services)
+    pipeline.SearchForIDs()
+    pipeline.GetProjectsAndPaginate('fake cnxn', '/hosting/search')
+    self.assertEqual(1, pipeline.pagination.start)
+    self.assertEqual(expected_last, pipeline.pagination.last)
+    self.assertEqual(expected_len, len(pipeline.visible_results))
+
+    return pipeline
+
+  def testZeroResults(self):
+    self.services.project.GetVisibleLiveProjects.return_value = []
+
+    pipeline = self.TestPipeline(0, 0)
+
+    self.services.project.GetVisibleLiveProjects.assert_called_once()
+    self.assertListEqual([], pipeline.visible_results)
+
+  def testNonzeroResults(self):
+    self.services.project.GetVisibleLiveProjects.return_value = [1, 2, 3]
+
+    pipeline = self.TestPipeline(3, 3)
+
+    self.services.project.GetVisibleLiveProjects.assert_called_once()
+    self.assertListEqual(
+        [1, 2, 3], [p.project_id for p in pipeline.visible_results])
+
+  def testTwoPageResults(self):
+    """Test more than one pagination page of results."""
+    self.services.project.GetVisibleLiveProjects.return_value = list(
+        range(1, 106))
+
+    pipeline = self.TestPipeline(100, 100)
+
+    self.services.project.GetVisibleLiveProjects.assert_called_once()
+    self.assertEqual(
+        '/hosting/search?num=100&start=100', pipeline.pagination.next_url)
diff --git a/sitewide/test/sitewide_helpers_test.py b/sitewide/test/sitewide_helpers_test.py
new file mode 100644
index 0000000..d292b6f
--- /dev/null
+++ b/sitewide/test/sitewide_helpers_test.py
@@ -0,0 +1,170 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the sitewide_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import project_pb2
+from services import service_manager
+from sitewide import sitewide_helpers
+from testing import fake
+
+
+REGULAR_USER_ID = 111
+ADMIN_USER_ID = 222
+OTHER_USER_ID = 333
+
+# Test project IDs
+REGULAR_OWNER_LIVE = 1001
+REGULAR_OWNER_ARCHIVED = 1002
+REGULAR_OWNER_DELETABLE = 1003
+REGULAR_COMMITTER_LIVE = 2001
+REGULAR_COMMITTER_ARCHIVED = 2002
+REGULAR_COMMITTER_DELETABLE = 2003
+OTHER_OWNER_LIVE = 3001
+OTHER_OWNER_ARCHIVED = 3002
+OTHER_OWNER_DELETABLE = 3003
+OTHER_COMMITTER_LIVE = 4001
+MEMBERS_ONLY = 5001
+
+
+class HelperFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        project_star=fake.ProjectStarService())
+    self.cnxn = 'fake cnxn'
+
+    for user_id in (ADMIN_USER_ID, REGULAR_USER_ID, OTHER_USER_ID):
+      self.services.user.TestAddUser('ignored_%s@gmail.com' % user_id, user_id)
+
+    self.regular_owner_live = self.services.project.TestAddProject(
+        'regular-owner-live', state=project_pb2.ProjectState.LIVE,
+        owner_ids=[REGULAR_USER_ID], project_id=REGULAR_OWNER_LIVE)
+    self.regular_owner_archived = self.services.project.TestAddProject(
+        'regular-owner-archived', state=project_pb2.ProjectState.ARCHIVED,
+        owner_ids=[REGULAR_USER_ID], project_id=REGULAR_OWNER_ARCHIVED)
+    self.regular_owner_deletable = self.services.project.TestAddProject(
+        'regular-owner-deletable', state=project_pb2.ProjectState.DELETABLE,
+        owner_ids=[REGULAR_USER_ID], project_id=REGULAR_OWNER_DELETABLE)
+    self.regular_committer_live = self.services.project.TestAddProject(
+        'regular-committer-live', state=project_pb2.ProjectState.LIVE,
+        committer_ids=[REGULAR_USER_ID], project_id=REGULAR_COMMITTER_LIVE)
+    self.regular_committer_archived = self.services.project.TestAddProject(
+        'regular-committer-archived', state=project_pb2.ProjectState.ARCHIVED,
+        committer_ids=[REGULAR_USER_ID], project_id=REGULAR_COMMITTER_ARCHIVED)
+    self.regular_committer_deletable = self.services.project.TestAddProject(
+        'regular-committer-deletable', state=project_pb2.ProjectState.DELETABLE,
+        committer_ids=[REGULAR_USER_ID], project_id=REGULAR_COMMITTER_DELETABLE)
+    self.other_owner_live = self.services.project.TestAddProject(
+        'other-owner-live', state=project_pb2.ProjectState.LIVE,
+        owner_ids=[OTHER_USER_ID], project_id=OTHER_OWNER_LIVE)
+    self.other_owner_archived = self.services.project.TestAddProject(
+        'other-owner-archived', state=project_pb2.ProjectState.ARCHIVED,
+        owner_ids=[OTHER_USER_ID], project_id=OTHER_OWNER_ARCHIVED)
+    self.other_owner_deletable = self.services.project.TestAddProject(
+        'other-owner-deletable', state=project_pb2.ProjectState.DELETABLE,
+        owner_ids=[OTHER_USER_ID], project_id=OTHER_OWNER_DELETABLE)
+    self.other_committer_live = self.services.project.TestAddProject(
+        'other-committer-live', state=project_pb2.ProjectState.LIVE,
+        committer_ids=[OTHER_USER_ID], project_id=OTHER_COMMITTER_LIVE)
+
+    self.regular_user = self.services.user.GetUser(self.cnxn, REGULAR_USER_ID)
+
+    self.admin_user = self.services.user.TestAddUser(
+        'administrator@chromium.org', ADMIN_USER_ID)
+    self.admin_user.is_site_admin = True
+
+    self.other_user = self.services.user.GetUser(self.cnxn, OTHER_USER_ID)
+
+    self.members_only_project = self.services.project.TestAddProject(
+        'members-only', owner_ids=[REGULAR_USER_ID], project_id=MEMBERS_ONLY)
+    self.members_only_project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+
+  def assertProjectsAnyOrder(self, actual_projects, *expected_projects):
+    # Check names rather than Project objects so that output is easier to read.
+    actual_names = [p.project_name for p in actual_projects]
+    expected_names = [p.project_name for p in expected_projects]
+    self.assertItemsEqual(expected_names, actual_names)
+
+  def testFilterViewableProjects_CantViewArchived(self):
+    projects = list(sitewide_helpers.FilterViewableProjects(
+        list(self.services.project.test_projects.values()),
+        self.regular_user, {REGULAR_USER_ID}))
+    self.assertProjectsAnyOrder(
+        projects, self.regular_owner_live, self.regular_committer_live,
+        self.other_owner_live, self.other_committer_live,
+        self.members_only_project)
+
+  def testFilterViewableProjects_NonMemberCantViewMembersOnly(self):
+    projects = list(sitewide_helpers.FilterViewableProjects(
+        list(self.services.project.test_projects.values()),
+        self.other_user, {OTHER_USER_ID}))
+    self.assertProjectsAnyOrder(
+        projects, self.regular_owner_live, self.regular_committer_live,
+        self.other_owner_live, self.other_committer_live)
+
+  def testFilterViewableProjects_AdminCanViewAny(self):
+    projects = list(sitewide_helpers.FilterViewableProjects(
+        list(self.services.project.test_projects.values()),
+        self.admin_user, {ADMIN_USER_ID}))
+    self.assertProjectsAnyOrder(
+        projects, self.regular_owner_live, self.regular_committer_live,
+        self.other_owner_live, self.other_committer_live,
+        self.members_only_project)
+
+  def testGetStarredProjects_OnlyViewableLiveStarred(self):
+    viewed_user_id = 123
+    for p in self.services.project.test_projects.values():
+      # We go straight to the services layer because this is a test set up
+      # rather than an actual user request.
+      self.services.project_star.SetStar(
+          self.cnxn, p.project_id, viewed_user_id, True)
+
+    self.assertProjectsAnyOrder(
+        sitewide_helpers.GetViewableStarredProjects(
+            self.cnxn, self.services, viewed_user_id,
+            {REGULAR_USER_ID}, self.regular_user),
+        self.regular_owner_live, self.regular_committer_live,
+        self.other_owner_live, self.other_committer_live,
+        self.members_only_project)
+
+  def testGetStarredProjects_MembersOnly(self):
+    # Both users were able to star the project in the past.  The stars do not
+    # go away even if access to the project changes.
+    self.services.project_star.SetStar(
+        self.cnxn, self.members_only_project.project_id, REGULAR_USER_ID, True)
+    self.services.project_star.SetStar(
+        self.cnxn, self.members_only_project.project_id, OTHER_USER_ID, True)
+
+    # But now, only one of them is currently a member, so only regular_user
+    # can see the starred project in the lists.
+    self.assertProjectsAnyOrder(
+        sitewide_helpers.GetViewableStarredProjects(
+            self.cnxn, self.services, REGULAR_USER_ID, {REGULAR_USER_ID},
+            self.regular_user),
+        self.members_only_project)
+    self.assertProjectsAnyOrder(
+        sitewide_helpers.GetViewableStarredProjects(
+            self.cnxn, self.services, OTHER_USER_ID, {REGULAR_USER_ID},
+            self.regular_user),
+        self.members_only_project)
+
+    # The other user cannot see the project, so they do not see it in either
+    # list of starred projects.
+    self.assertProjectsAnyOrder(
+        sitewide_helpers.GetViewableStarredProjects(
+            self.cnxn, self.services, REGULAR_USER_ID, {OTHER_USER_ID},
+            self.other_user))  # No expected projects listed.
+    self.assertProjectsAnyOrder(
+        sitewide_helpers.GetViewableStarredProjects(
+            self.cnxn, self.services, OTHER_USER_ID, {OTHER_USER_ID},
+            self.other_user))  # No expected projects listed.
diff --git a/sitewide/test/sitewide_views_test.py b/sitewide/test/sitewide_views_test.py
new file mode 100644
index 0000000..ed2515f
--- /dev/null
+++ b/sitewide/test/sitewide_views_test.py
@@ -0,0 +1,26 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for sitewide_views module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import usergroup_pb2
+from sitewide import sitewide_views
+
+
+class GroupViewTest(unittest.TestCase):
+
+  def testConstructor(self):
+    group_settings = usergroup_pb2.MakeSettings('anyone')
+    view = sitewide_views.GroupView('groupname', 123, group_settings, 999)
+
+    self.assertEqual('groupname', view.name)
+    self.assertEqual(123, view.num_members)
+    self.assertEqual('ANYONE', view.who_can_view_members)
+    self.assertEqual('/g/999/', view.detail_url)
diff --git a/sitewide/test/userprofile_test.py b/sitewide/test/userprofile_test.py
new file mode 100644
index 0000000..b830fb7
--- /dev/null
+++ b/sitewide/test/userprofile_test.py
@@ -0,0 +1,252 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the user profile page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import logging
+import webapp2
+import ezt
+
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from proto import project_pb2
+from proto import user_pb2
+from services import service_manager
+from sitewide import userprofile
+from testing import fake
+from testing import testing_helpers
+
+from google.appengine.ext import testbed
+
+REGULAR_USER_ID = 111
+ADMIN_USER_ID = 222
+OTHER_USER_ID = 333
+STATES = {
+    'live': project_pb2.ProjectState.LIVE,
+    'archived': project_pb2.ProjectState.ARCHIVED,
+}
+
+
+def MakeReqInfo(
+    user_pb, user_id, viewed_user_pb, viewed_user_id, viewed_user_name,
+    perms=permissions.USER_PERMISSIONSET):
+  mr = fake.MonorailRequest(None, perms=perms)
+  mr.auth.user_pb = user_pb
+  mr.auth.user_id = user_id
+  mr.auth.effective_ids = {user_id}
+  mr.viewed_user_auth.email = viewed_user_name
+  mr.viewed_user_auth.user_pb = viewed_user_pb
+  mr.viewed_user_auth.user_id = viewed_user_id
+  mr.viewed_user_auth.effective_ids = {viewed_user_id}
+  mr.viewed_user_auth.user_view = framework_views.UserView(viewed_user_pb)
+  mr.viewed_user_name = viewed_user_name
+  mr.request = webapp2.Request.blank("/")
+  return mr
+
+
+class UserProfileTest(unittest.TestCase):
+
+  def setUp(self):
+    self.patcher_1 = mock.patch(
+      'framework.framework_helpers.UserSettings.GatherUnifiedSettingsPageData')
+    self.mock_guspd = self.patcher_1.start()
+    self.mock_guspd.return_value = {'unified': None}
+
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project_star=fake.ProjectStarService(),
+        user_star=fake.UserStarService())
+    self.servlet = userprofile.UserProfile('req', 'res', services=services)
+
+    for user_id in (
+        REGULAR_USER_ID, ADMIN_USER_ID, OTHER_USER_ID):
+      services.user.TestAddUser('%s@gmail.com' % user_id, user_id)
+
+    for user in ['regular', 'other']:
+      for relation in ['owner', 'member']:
+        for state_name, state in STATES.items():
+          services.project.TestAddProject(
+              '%s-%s-%s' % (user, relation, state_name), state=state)
+
+    # Add projects
+    for state_name, state in STATES.items():
+      services.project.TestAddProject(
+          'regular-owner-%s' % state_name, state=state,
+          owner_ids=[REGULAR_USER_ID])
+      services.project.TestAddProject(
+          'regular-member-%s' % state_name, state=state,
+          committer_ids=[REGULAR_USER_ID])
+      services.project.TestAddProject(
+          'other-owner-%s' % state_name, state=state,
+          owner_ids=[OTHER_USER_ID])
+      services.project.TestAddProject(
+          'other-member-%s' % state_name, state=state,
+          committer_ids=[OTHER_USER_ID])
+
+    self.regular_user = services.user.GetUser('fake cnxn', REGULAR_USER_ID)
+    self.admin_user = services.user.GetUser('fake cnxn', ADMIN_USER_ID)
+    self.admin_user.is_site_admin = True
+    self.other_user = services.user.GetUser('fake cnxn', OTHER_USER_ID)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    mock.patch.stopall()
+
+  def assertProjectsAnyOrder(self, value_to_test, *expected_project_names):
+    actual_project_names = [project_view.project_name
+                            for project_view in value_to_test]
+    self.assertItemsEqual(expected_project_names, actual_project_names)
+
+  def testGatherPageData_RegularUserViewingOtherUserProjects(self):
+    """A user can see the other users' live projects, but not archived ones."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertProjectsAnyOrder(page_data['owner_of_projects'],
+                                'other-owner-live')
+    self.assertProjectsAnyOrder(page_data['committer_of_projects'],
+                                'other-member-live')
+    self.assertFalse(page_data['owner_of_archived_projects'])
+    self.assertEqual('ot...@xyz.com', page_data['viewed_user_display_name'])
+    self.assertEqual(ezt.boolean(False), page_data['can_delete_user'])
+    self.mock_guspd.assert_called_once_with(
+        111, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        None)
+
+  def testGatherPageData_RegularUserViewingOwnProjects(self):
+    """A user can see all their own projects: live or archived."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.regular_user,
+        REGULAR_USER_ID, 'self@xyz.com')
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual('self@xyz.com', page_data['viewed_user_display_name'])
+    self.assertEqual(ezt.boolean(False), page_data['can_delete_user'])
+    self.assertProjectsAnyOrder(page_data['owner_of_projects'],
+                                'regular-owner-live')
+    self.assertProjectsAnyOrder(page_data['committer_of_projects'],
+                                'regular-member-live')
+    self.assertProjectsAnyOrder(
+        page_data['owner_of_archived_projects'],
+        'regular-owner-archived')
+    self.mock_guspd.assert_called_once_with(
+        111, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        None)
+
+  def testGatherPageData_RegularUserViewingStarredUsers(self):
+    """A user can see display names of other users that they starred."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.regular_user,
+        REGULAR_USER_ID, 'self@xyz.com')
+    self.servlet.services.user_star.SetStar(
+        'cnxn', OTHER_USER_ID, REGULAR_USER_ID, True)
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    starred_users = page_data['starred_users']
+    self.assertEqual(1, len(starred_users))
+    self.assertEqual('333@gmail.com', starred_users[0].email)
+    self.assertEqual('["3...@gmail.com"]', page_data['starred_users_json'])
+    self.mock_guspd.assert_called_once_with(
+        111, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        None)
+
+  def testGatherPageData_AdminViewingOtherUserAddress(self):
+    """Site admins always see full email addresses of other users."""
+    mr = MakeReqInfo(
+        self.admin_user, ADMIN_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com',
+        perms=permissions.ADMIN_PERMISSIONSET)
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual('other@xyz.com', page_data['viewed_user_display_name'])
+    self.assertEqual(ezt.boolean(True), page_data['can_delete_user'])
+    self.mock_guspd.assert_called_once_with(
+        222, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        mock.ANY)
+
+  def testGatherPageData_RegularUserViewingOtherUserAddressUnobscured(self):
+    """Email should be revealed to others depending on obscure_email."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+    mr.viewed_user_auth.user_view.obscure_email = False
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual('other@xyz.com', page_data['viewed_user_display_name'])
+    self.mock_guspd.assert_called_once_with(
+        111, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        None)
+
+  def testGatherPageData_RegularUserViewingOtherUserAddressObscured(self):
+    """Email should be revealed to others depending on obscure_email."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+    mr.viewed_user_auth.user_view.obscure_email = True
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual('ot...@xyz.com', page_data['viewed_user_display_name'])
+    self.assertEqual(ezt.boolean(False), page_data['can_delete_user'])
+    self.mock_guspd.assert_called_once_with(
+        111, mr.viewed_user_auth.user_view, mr.viewed_user_auth.user_pb,
+        None)
+
+  def testGatherPageData_NoLinkedAccounts(self):
+    """An account with no linked accounts should not show anything linked."""
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertIsNone(page_data['linked_parent'])
+    self.assertEqual([], page_data['linked_children'])
+
+  def testGatherPageData_ParentAccounts(self):
+    """An account with a parent linked account should show it."""
+    self.other_user.linked_parent_id = REGULAR_USER_ID
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual('111@gmail.com', page_data['linked_parent'].email)
+    self.assertEqual([], page_data['linked_children'])
+
+  def testGatherPageData_ChildAccounts(self):
+    """An account with a child linked account should show them."""
+    self.other_user.linked_child_ids = [REGULAR_USER_ID, ADMIN_USER_ID]
+    mr = MakeReqInfo(
+        self.regular_user, REGULAR_USER_ID, self.other_user,
+        OTHER_USER_ID, 'other@xyz.com')
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual(None, page_data['linked_parent'])
+    self.assertEqual(
+        ['111@gmail.com', '222@gmail.com'],
+        [uv.email for uv in page_data['linked_children']])
diff --git a/sitewide/test/usersettings_test.py b/sitewide/test/usersettings_test.py
new file mode 100644
index 0000000..54c14ae
--- /dev/null
+++ b/sitewide/test/usersettings_test.py
@@ -0,0 +1,66 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the user settings page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+from proto import user_pb2
+from services import service_manager
+from sitewide import usersettings
+from testing import fake
+from testing import testing_helpers
+
+
+class UserSettingsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.services = service_manager.Services(user=fake.UserService())
+    self.servlet = usersettings.UserSettings(
+        'req', 'res', services=self.services)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+
+  def testAssertBasePermission(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.auth.user_id = 111
+
+    # The following should return without exception.
+    self.servlet.AssertBasePermission(mr)
+
+    # No logged in user means anonymous access, should raise error.
+    mr.auth.user_id = 0
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+  def testGatherPageData(self):
+    self.mox.StubOutWithMock(
+        framework_helpers.UserSettings, 'GatherUnifiedSettingsPageData')
+    framework_helpers.UserSettings.GatherUnifiedSettingsPageData(
+        0, None, mox.IsA(user_pb2.User), mox.IsA(user_pb2.UserPrefs)
+        ).AndReturn({'unified': None})
+    self.mox.ReplayAll()
+
+    mr = testing_helpers.MakeMonorailRequest()
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertItemsEqual(
+        ['logged_in_user_pb', 'unified', 'user_tab_mode',
+         'viewed_user', 'offer_saved_queries_subtab', 'viewing_self'],
+        list(page_data.keys()))
+    self.assertEqual(template_helpers.PBProxy(mr.auth.user_pb),
+                     page_data['logged_in_user_pb'])
+
+    self.mox.VerifyAll()
diff --git a/sitewide/test/userupdates_test.py b/sitewide/test/userupdates_test.py
new file mode 100644
index 0000000..efae9bc
--- /dev/null
+++ b/sitewide/test/userupdates_test.py
@@ -0,0 +1,115 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.sitewide.userupdates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from services import service_manager
+from sitewide import sitewide_helpers
+from sitewide import userupdates
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectUpdatesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user_star=fake.UserStarService())
+
+    self.user_id = 2
+    self.project_id = 987
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=self.project_id)
+
+    self.mr = testing_helpers.MakeMonorailRequest(
+        services=self.services, project=self.project)
+    self.mr.cnxn = 'fake cnxn'
+    self.mr.viewed_user_auth.user_id = 100
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testUserUpdatesProjects(self):
+    uup = userupdates.UserUpdatesProjects(None, None, self.services)
+
+    self.mox.StubOutWithMock(sitewide_helpers, 'GetViewableStarredProjects')
+    sitewide_helpers.GetViewableStarredProjects(
+        self.mr.cnxn, self.services, self.mr.viewed_user_auth.user_id,
+        self.mr.auth.effective_ids, self.mr.auth.user_pb).AndReturn(
+            [self.project])
+
+    self.mox.StubOutWithMock(activities, 'GatherUpdatesData')
+    activities.GatherUpdatesData(
+        self.services, self.mr, user_ids=None,
+        project_ids=[self.project_id],
+        ending=uup._ENDING,
+        updates_page_url=uup._UPDATES_PAGE_URL,
+        highlight=uup._HIGHLIGHT).AndReturn({})
+
+    self.mox.ReplayAll()
+
+    page_data = uup.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertEqual(3, len(page_data))
+    self.assertEqual('st5', page_data['user_tab_mode'])
+    self.assertEqual('yes', page_data['viewing_user_page'])
+    self.assertEqual(uup._TAB_MODE, page_data['user_updates_tab_mode'])
+
+  def testUserUpdatesDevelopers(self):
+    uud = userupdates.UserUpdatesDevelopers(None, None, self.services)
+
+    self.mox.StubOutWithMock(self.services.user_star, 'LookupStarredItemIDs')
+    self.services.user_star.LookupStarredItemIDs(
+        self.mr.cnxn, self.mr.viewed_user_auth.user_id).AndReturn(
+            [self.user_id])
+
+    self.mox.StubOutWithMock(activities, 'GatherUpdatesData')
+    activities.GatherUpdatesData(
+        self.services, self.mr, user_ids=[self.user_id],
+        project_ids=None, ending=uud._ENDING,
+        updates_page_url=uud._UPDATES_PAGE_URL,
+        highlight=uud._HIGHLIGHT).AndReturn({})
+
+    self.mox.ReplayAll()
+
+    page_data = uud.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertEqual(3, len(page_data))
+    self.assertEqual('st5', page_data['user_tab_mode'])
+    self.assertEqual('yes', page_data['viewing_user_page'])
+    self.assertEqual(uud._TAB_MODE, page_data['user_updates_tab_mode'])
+
+  def testUserUpdatesIndividual(self):
+    uui = userupdates.UserUpdatesIndividual(None, None, self.services)
+
+    self.mox.StubOutWithMock(activities, 'GatherUpdatesData')
+    activities.GatherUpdatesData(
+        self.services, self.mr,
+        user_ids=[self.mr.viewed_user_auth.user_id],
+        project_ids=None, ending=uui._ENDING,
+        updates_page_url=uui._UPDATES_PAGE_URL,
+        highlight=uui._HIGHLIGHT).AndReturn({})
+
+    self.mox.ReplayAll()
+
+    page_data = uui.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertEqual(3, len(page_data))
+    self.assertEqual('st5', page_data['user_tab_mode'])
+    self.assertEqual('yes', page_data['viewing_user_page'])
+    self.assertEqual(uui._TAB_MODE, page_data['user_updates_tab_mode'])
+
diff --git a/sitewide/userclearbouncing.py b/sitewide/userclearbouncing.py
new file mode 100644
index 0000000..3decdf4
--- /dev/null
+++ b/sitewide/userclearbouncing.py
@@ -0,0 +1,62 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Class to show a servlet to clear a user's bouncing email timestamp."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import timestr
+
+
+class UserClearBouncing(servlet.Servlet):
+  """Shows a page that can clear a user's bouncing email timestamp."""
+
+  _PAGE_TEMPLATE = 'sitewide/user-clear-bouncing-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(UserClearBouncing, self).AssertBasePermission(mr)
+    if mr.auth.user_id == mr.viewed_user_auth.user_id:
+      return
+    if mr.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
+      return
+    raise permissions.PermissionException('You cannot edit this user.')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    viewed_user = mr.viewed_user_auth.user_pb
+    if viewed_user.email_bounce_timestamp:
+      last_bounce_str = timestr.FormatRelativeDate(
+          viewed_user.email_bounce_timestamp, days_only=True)
+      last_bounce_str = last_bounce_str or 'Less than 2 days ago'
+    else:
+      last_bounce_str = None
+
+    page_data = {
+        'user_tab_mode': 'st2',
+        'last_bounce_str': last_bounce_str,
+        }
+    return page_data
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    viewed_user = mr.viewed_user_auth.user_pb
+    viewed_user.email_bounce_timestamp = None
+    self.services.user.UpdateUser(
+        mr.cnxn, viewed_user.user_id, viewed_user)
+    return framework_helpers.FormatAbsoluteURL(
+        mr, mr.viewed_user_auth.user_view.profile_url, include_project=False,
+        saved=1, ts=int(time.time()))
diff --git a/sitewide/userprofile.py b/sitewide/userprofile.py
new file mode 100644
index 0000000..bf68c5f
--- /dev/null
+++ b/sitewide/userprofile.py
@@ -0,0 +1,271 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes for the user profile page ("my page")."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+import json
+
+import ezt
+
+import settings
+from businesslogic import work_env
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import timestr
+from framework import xsrf
+from project import project_views
+from sitewide import sitewide_helpers
+
+
+class UserProfile(servlet.Servlet):
+  """Shows a page of information about a user."""
+
+  _PAGE_TEMPLATE = 'sitewide/user-profile-page.ezt'
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    viewed_user = mr.viewed_user_auth.user_pb
+    if self.services.usergroup.GetGroupSettings(
+        mr.cnxn, mr.viewed_user_auth.user_id):
+      url = framework_helpers.FormatAbsoluteURL(
+          mr, '/g/%s/' % viewed_user.email, include_project=False)
+      self.redirect(url, abort=True)  # Show group page instead.
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      project_lists = we.GetUserProjects(mr.viewed_user_auth.effective_ids)
+
+      (visible_ownership, visible_archived, visible_membership,
+       visible_contrib) = project_lists
+
+    with mr.profiler.Phase('Getting user groups'):
+      group_settings = self.services.usergroup.GetAllGroupSettings(
+          mr.cnxn, mr.viewed_user_auth.effective_ids)
+      member_ids, owner_ids = self.services.usergroup.LookupAllMembers(
+          mr.cnxn, list(group_settings.keys()))
+      friend_project_ids = [] # TODO(issue 4202): implement this.
+      visible_group_ids = []
+      for group_id in group_settings:
+        if permissions.CanViewGroupMembers(
+            mr.perms, mr.auth.effective_ids, group_settings[group_id],
+            member_ids[group_id], owner_ids[group_id], friend_project_ids):
+          visible_group_ids.append(group_id)
+
+      user_group_views = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, visible_group_ids)
+      user_group_views = sorted(
+          list(user_group_views.values()), key=lambda ugv: ugv.email)
+
+    with mr.profiler.Phase('Getting linked accounts'):
+      linked_parent = None
+      linked_children = []
+      linked_views = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user,
+          [viewed_user.linked_parent_id],
+          viewed_user.linked_child_ids)
+      if viewed_user.linked_parent_id:
+        linked_parent = linked_views[viewed_user.linked_parent_id]
+      if viewed_user.linked_child_ids:
+        linked_children = [
+          linked_views[child_id] for child_id in viewed_user.linked_child_ids]
+      offer_unlink = (mr.auth.user_id == viewed_user.user_id or
+                      mr.auth.user_id in linked_views)
+
+    incoming_invite_users = []
+    outgoing_invite_users = []
+    possible_parent_accounts = []
+    can_edit_invites = mr.auth.user_id == mr.viewed_user_auth.user_id
+    display_link_invites = can_edit_invites or mr.auth.user_pb.is_site_admin
+    # TODO(jrobbins): allow site admin to edit invites for other users.
+    if display_link_invites:
+      with work_env.WorkEnv(mr, self.services, phase='Getting link invites'):
+        incoming_invite_ids, outgoing_invite_ids = we.GetPendingLinkedInvites(
+            user_id=viewed_user.user_id)
+        invite_views = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, incoming_invite_ids, outgoing_invite_ids)
+        incoming_invite_users = [
+            invite_views[uid] for uid in incoming_invite_ids]
+        outgoing_invite_users = [
+            invite_views[uid] for uid in outgoing_invite_ids]
+        possible_parent_accounts = _ComputePossibleParentAccounts(
+            we, mr.viewed_user_auth.user_view, linked_parent, linked_children)
+
+    viewed_user_display_name = framework_views.GetViewedUserDisplayName(mr)
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      starred_projects = we.ListStarredProjects(
+          viewed_user_id=mr.viewed_user_auth.user_id)
+      logged_in_starred = we.ListStarredProjects()
+      logged_in_starred_pids = {p.project_id for p in logged_in_starred}
+
+    starred_user_ids = self.services.user_star.LookupStarredItemIDs(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+    starred_user_dict = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user, starred_user_ids)
+    starred_users = list(starred_user_dict.values())
+    starred_users_json = json.dumps(
+      [uv.display_name for uv in starred_users])
+
+    is_user_starred = self._IsUserStarred(
+        mr.cnxn, mr.auth.user_id, mr.viewed_user_auth.user_id)
+
+    if viewed_user.last_visit_timestamp:
+      last_visit_str = timestr.FormatRelativeDate(
+          viewed_user.last_visit_timestamp, days_only=True)
+      last_visit_str = last_visit_str or 'Less than 2 days ago'
+    else:
+      last_visit_str = 'Never'
+
+    if viewed_user.email_bounce_timestamp:
+      last_bounce_str = timestr.FormatRelativeDate(
+          viewed_user.email_bounce_timestamp, days_only=True)
+      last_bounce_str = last_bounce_str or 'Less than 2 days ago'
+    else:
+      last_bounce_str = None
+
+    can_ban = permissions.CanBan(mr, self.services)
+    viewed_user_is_spammer = viewed_user.banned.lower() == 'spam'
+    viewed_user_may_be_spammer = not viewed_user_is_spammer
+    all_projects = self.services.project.GetAllProjects(mr.cnxn)
+    for project_id in all_projects:
+      project = all_projects[project_id]
+      viewed_user_perms = permissions.GetPermissions(viewed_user,
+          mr.viewed_user_auth.effective_ids, project)
+      if (viewed_user_perms != permissions.EMPTY_PERMISSIONSET and
+          viewed_user_perms != permissions.USER_PERMISSIONSET):
+        viewed_user_may_be_spammer = False
+
+    ban_token = None
+    ban_spammer_token = None
+    if mr.auth.user_id and can_ban:
+      form_token_path = mr.request.path + 'ban.do'
+      ban_token = xsrf.GenerateToken(mr.auth.user_id, form_token_path)
+      form_token_path = mr.request.path + 'banSpammer.do'
+      ban_spammer_token = xsrf.GenerateToken(mr.auth.user_id, form_token_path)
+
+    can_delete_user = permissions.CanExpungeUsers(mr)
+
+    page_data = {
+        'user_tab_mode': 'st2',
+        'viewed_user_display_name': viewed_user_display_name,
+        'viewed_user_may_be_spammer': ezt.boolean(viewed_user_may_be_spammer),
+        'viewed_user_is_spammer': ezt.boolean(viewed_user_is_spammer),
+        'viewed_user_is_banned': ezt.boolean(viewed_user.banned),
+        'owner_of_projects': [
+            project_views.ProjectView(
+                p, starred=p.project_id in logged_in_starred_pids)
+            for p in visible_ownership],
+        'committer_of_projects': [
+            project_views.ProjectView(
+                p, starred=p.project_id in logged_in_starred_pids)
+            for p in visible_membership],
+        'contributor_to_projects': [
+            project_views.ProjectView(
+                p, starred=p.project_id in logged_in_starred_pids)
+            for p in visible_contrib],
+        'owner_of_archived_projects': [
+            project_views.ProjectView(p) for p in visible_archived],
+        'starred_projects': [
+            project_views.ProjectView(
+                p, starred=p.project_id in logged_in_starred_pids)
+            for p in starred_projects],
+        'starred_users': starred_users,
+        'starred_users_json': starred_users_json,
+        'is_user_starred': ezt.boolean(is_user_starred),
+        'viewing_user_page': ezt.boolean(True),
+        'last_visit_str': last_visit_str,
+        'last_bounce_str': last_bounce_str,
+        'vacation_message': viewed_user.vacation_message,
+        'can_ban': ezt.boolean(can_ban),
+        'ban_token': ban_token,
+        'ban_spammer_token': ban_spammer_token,
+        'user_groups': user_group_views,
+        'linked_parent': linked_parent,
+        'linked_children': linked_children,
+        'incoming_invite_users': incoming_invite_users,
+        'outgoing_invite_users': outgoing_invite_users,
+        'possible_parent_accounts': possible_parent_accounts,
+        'can_edit_invites': ezt.boolean(can_edit_invites),
+        'offer_unlink': ezt.boolean(offer_unlink),
+        'can_delete_user': ezt.boolean(can_delete_user),
+        }
+
+    viewed_user_prefs = None
+    if mr.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
+      with work_env.WorkEnv(mr, self.services) as we:
+        viewed_user_prefs = we.GetUserPrefs(mr.viewed_user_auth.user_id)
+
+    user_settings = (
+        framework_helpers.UserSettings.GatherUnifiedSettingsPageData(
+        mr.auth.user_id, mr.viewed_user_auth.user_view, viewed_user,
+        viewed_user_prefs))
+    page_data.update(user_settings)
+
+    return page_data
+
+  def _IsUserStarred(self, cnxn, logged_in_user_id, viewed_user_id):
+    """Return whether the logged in user starred the viewed user."""
+    if logged_in_user_id:
+      return self.services.user_star.IsItemStarredBy(
+          cnxn, viewed_user_id, logged_in_user_id)
+    return False
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    has_admin_perm = mr.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None)
+    with work_env.WorkEnv(mr, self.services) as we:
+      framework_helpers.UserSettings.ProcessSettingsForm(
+          we, post_data, mr.viewed_user_auth.user_pb, admin=has_admin_perm)
+
+    # TODO(jrobbins): Check all calls to FormatAbsoluteURL for include_project.
+    return framework_helpers.FormatAbsoluteURL(
+        mr, mr.viewed_user_auth.user_view.profile_url, include_project=False,
+        saved=1, ts=int(time.time()))
+
+
+def _ComputePossibleParentAccounts(
+    we, user_view, linked_parent, linked_children):
+  """Return a list of email addresses of possible parent accounts."""
+  if not user_view:
+    return []  # Anon user cannot link to any account.
+  if linked_parent or linked_children:
+    return []  # If account is already linked in any way, don't offer.
+  possible_domains = settings.linkable_domains.get(user_view.domain, [])
+  possible_emails = ['%s@%s' % (user_view.username, domain)
+                     for domain in possible_domains]
+  found_users, _ = we.ListReferencedUsers(possible_emails)
+  found_emails = [user.email for user in found_users]
+  return found_emails
+
+
+class UserProfilePolymer(UserProfile):
+  """New Polymer version of user profiles in Monorail."""
+
+  _PAGE_TEMPLATE = 'sitewide/user-profile-page-polymer.ezt'
+
+
+class BanUser(servlet.Servlet):
+  """Bans or un-bans a user."""
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    if not permissions.CanBan(mr, self.services):
+      raise permissions.PermissionException(
+          "You do not have permission to ban users.")
+
+    framework_helpers.UserSettings.ProcessBanForm(
+        mr.cnxn, self.services.user, post_data, mr.viewed_user_auth.user_id,
+        mr.viewed_user_auth.user_pb)
+
+    # TODO(jrobbins): Check all calls to FormatAbsoluteURL for include_project.
+    return framework_helpers.FormatAbsoluteURL(
+        mr, mr.viewed_user_auth.user_view.profile_url, include_project=False,
+        saved=1, ts=int(time.time()))
diff --git a/sitewide/usersettings.py b/sitewide/usersettings.py
new file mode 100644
index 0000000..bb65ddd
--- /dev/null
+++ b/sitewide/usersettings.py
@@ -0,0 +1,65 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes for the user settings (preferences) page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import urllib
+
+import ezt
+
+from businesslogic import work_env
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+
+
+class UserSettings(servlet.Servlet):
+  """Shows a page with a simple form to edit user preferences."""
+
+  _PAGE_TEMPLATE = 'sitewide/user-settings-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Assert that the user has the permissions needed to view this page."""
+    super(UserSettings, self).AssertBasePermission(mr)
+
+    if not mr.auth.user_id:
+      raise permissions.PermissionException(
+          'Anonymous users are not allowed to edit user settings')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    page_data = {
+        'user_tab_mode': 'st3',
+        'logged_in_user_pb': template_helpers.PBProxy(mr.auth.user_pb),
+        # When on /hosting/settings, the logged-in user is the viewed user.
+        'viewed_user': mr.auth.user_view,
+        'offer_saved_queries_subtab': ezt.boolean(True),
+        'viewing_self': ezt.boolean(True),
+        }
+    with work_env.WorkEnv(mr, self.services) as we:
+      settings_user_prefs = we.GetUserPrefs(mr.auth.user_id)
+    page_data.update(
+        framework_helpers.UserSettings.GatherUnifiedSettingsPageData(
+            mr.auth.user_id, mr.auth.user_view, mr.auth.user_pb,
+            settings_user_prefs))
+    return page_data
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    with work_env.WorkEnv(mr, self.services) as we:
+      framework_helpers.UserSettings.ProcessSettingsForm(
+          we, post_data, mr.auth.user_pb)
+
+    url = framework_helpers.FormatAbsoluteURL(
+        mr, urls.USER_SETTINGS, include_project=False,
+        saved=1, ts=int(time.time()))
+
+    return url
diff --git a/sitewide/userupdates.py b/sitewide/userupdates.py
new file mode 100644
index 0000000..ac44c0f
--- /dev/null
+++ b/sitewide/userupdates.py
@@ -0,0 +1,118 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes for user updates pages.
+
+  AbstractUserUpdatesPage: Base class for all user updates pages
+  UserUpdatesProjects: Handles displaying starred projects
+  UserUpdatesDevelopers: Handles displaying starred developers
+  UserUpdatesIndividual: Handles displaying activities by the viewed user
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+
+import ezt
+
+from businesslogic import work_env
+from features import activities
+from framework import servlet
+from framework import urls
+from sitewide import sitewide_helpers
+
+
+class AbstractUserUpdatesPage(servlet.Servlet):
+  """Base class for user updates pages."""
+
+  _PAGE_TEMPLATE = 'sitewide/user-updates-page.ezt'
+
+  # Subclasses should override these constants.
+  _UPDATES_PAGE_URL = None
+  # What to highlight in the middle column on user updates pages - 'project',
+  # 'user', or None
+  _HIGHLIGHT = None
+  # What the ending phrase for activity titles should be - 'by_user',
+  # 'in_project', or None
+  _ENDING = None
+  _TAB_MODE = None
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    # TODO(jrobbins): re-implement
+    # if self.CheckRevelationCaptcha(mr, mr.errors):
+    #   mr.viewed_user_auth.user_view.RevealEmail()
+
+    page_data = {
+        'user_tab_mode': 'st5',
+        'viewing_user_page': ezt.boolean(True),
+        'user_updates_tab_mode': self._TAB_MODE,
+        }
+
+    user_ids = self._GetUserIDsForUpdates(mr)
+    project_ids = self._GetProjectIDsForUpdates(mr)
+    page_data.update(activities.GatherUpdatesData(
+        self.services, mr, user_ids=user_ids,
+        project_ids=project_ids, ending=self._ENDING,
+        updates_page_url=self._UPDATES_PAGE_URL, highlight=self._HIGHLIGHT))
+
+    return page_data
+
+  def _GetUserIDsForUpdates(self, _mr):
+    """Returns a list of user IDs to retrieve activities from."""
+    return None  # Means any.
+
+  def _GetProjectIDsForUpdates(self, _mr):
+    """Returns a list of project IDs to retrieve activities from."""
+    return None  # Means any.
+
+
+class UserUpdatesProjects(AbstractUserUpdatesPage):
+  """Shows a page of updates from projects starred by a user."""
+
+  _UPDATES_FEED_URL = urls.USER_UPDATES_PROJECTS
+  _UPDATES_PAGE_URL = urls.USER_UPDATES_PROJECTS
+  _HIGHLIGHT = 'project'
+  _ENDING = 'by_user'
+  _TAB_MODE = 'st2'
+
+  def _GetProjectIDsForUpdates(self, mr):
+    """Returns a list of project IDs whom to retrieve activities from."""
+    with work_env.WorkEnv(mr, self.services) as we:
+      starred_projects = we.ListStarredProjects(
+          viewed_user_id=mr.viewed_user_auth.user_id)
+    return [project.project_id for project in starred_projects]
+
+
+class UserUpdatesDevelopers(AbstractUserUpdatesPage):
+  """Shows a page of updates from developers starred by a user."""
+
+  _UPDATES_FEED_URL = urls.USER_UPDATES_DEVELOPERS
+  _UPDATES_PAGE_URL = urls.USER_UPDATES_DEVELOPERS
+  _HIGHLIGHT = 'user'
+  _ENDING = 'in_project'
+  _TAB_MODE = 'st3'
+
+  def _GetUserIDsForUpdates(self, mr):
+    """Returns a list of user IDs whom to retrieve activities from."""
+    user_ids = self.services.user_star.LookupStarredItemIDs(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+    logging.debug('StarredUsers: %r', user_ids)
+    return user_ids
+
+
+class UserUpdatesIndividual(AbstractUserUpdatesPage):
+  """Shows a page of updates initiated by a user."""
+
+  _UPDATES_FEED_URL = urls.USER_UPDATES_MINE + '/user'
+  _UPDATES_PAGE_URL = urls.USER_UPDATES_MINE
+  _HIGHLIGHT = 'project'
+  _TAB_MODE = 'st1'
+
+  def _GetUserIDsForUpdates(self, mr):
+    """Returns a list of user IDs whom to retrieve activities from."""
+    return [mr.viewed_user_auth.user_id]