Repository: osmlab/maproulette3
Pull request #1232 (2020-05-14 18:03:09)

Support Teams
> Note: this depends on backend PR maproulette/maproulette2#787

* Add Teams widget available on User Dashboard for creating and
managing teams, as well as membership on teams

* Allow teams to be added as project managers via the Project Managers
administration widget

* Add Apollo graphQL client and use graphQL for Teams widget


Patch:
diff --git a/.env b/.env
index 64b86e67..21c70f89 100644
--- a/.env
+++ b/.env
@@ -24,6 +24,7 @@ REACT_APP_DEFAULT_LOCALE='en-US'
 # MR2 back-end (scala) server
 REACT_APP_MAP_ROULETTE_SERVER_URL='http://127.0.0.1:9000'
 REACT_APP_MAP_ROULETTE_SERVER_WEBSOCKET_URL='ws://127.0.0.1:9000/ws'
+REACT_APP_MAP_ROULETTE_SERVER_GRAPHQL_URL='http://127.0.0.1:9000/graphql'
 REACT_APP_SERVER_OAUTH_URL='/auth/authenticate?redirect=/mr3'
 
 # OSM API Server
diff --git a/package.json b/package.json
index 6e2adf2b..8ce839fb 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
   "version": "3.6.1",
   "private": true,
   "dependencies": {
+    "@apollo/client": "^3.0.0-beta.44",
     "@mapbox/geo-viewport": "^0.4.0",
     "@mapbox/geojsonhint": "^2.0.1",
     "@nivo/bar": "^0.61.1",
@@ -27,6 +28,7 @@
     "downshift": "^2.0.3",
     "file-saver": "^2.0.2",
     "fuse.js": "^3.1.0",
+    "graphql": "^15.0.0",
     "handlebars": "^4.2.0",
     "leaflet": "^1.5.1",
     "leaflet-lasso": "^2.0.4",
diff --git a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
index d26598cb..3198ec54 100644
--- a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
+++ b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
@@ -17,6 +17,8 @@ import { fetchManageableProjects,
          saveProject,
          removeProject,
          deleteProject} from '../../../../services/Project/Project'
+import { setTeamProjectRole, removeTeamFromProject }
+       from '../../../../services/Team/Team'
 import { addChallenge,
          removeChallenge } from '../../../../services/Project/VirtualProject'
 import { fetchProjectChallengeListing }
@@ -123,9 +125,11 @@ const mapDispatchToProps = dispatch => {
     fetchProjectChallengeListing,
     saveProject,
     addProjectManager,
+    setTeamProjectRole,
     fetchProjectManagers,
     setProjectManagerRole,
     removeProjectManager,
+    removeTeamFromProject,
     addChallenge,
     removeChallenge,
   }, dispatch)
diff --git a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js
index 3ec7594f..94b5fbc1 100644
--- a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js
+++ b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js
@@ -39,8 +39,28 @@ export default defineMessages({
     defaultMessage: "Choose Role"
   },
 
-  osmUsername: {
+  osmUsernamePlaceholder: {
     id: "Admin.ProjectManagers.controls.chooseOSMUser.placeholder",
     defaultMessage: "OpenStreetMap username"
   },
+
+  teamNamePlaceholder: {
+    id: "Admin.ProjectManagers.controls.chooseTeam.placeholder",
+    defaultMessage: "Team name"
+  },
+
+  teamOption: {
+    id: "Admin.ProjectManagers.options.teams.label",
+    defaultMessage: "Team"
+  },
+
+  userOption: {
+    id: "Admin.ProjectManagers.options.users.label",
+    defaultMessage: "User"
+  },
+
+  teamIndicator: {
+    id: "Admin.ProjectManagers.team.indicator",
+    defaultMessage: "Team"
+  },
 })
diff --git a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
index 6d446dc8..da95e93b 100644
--- a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
+++ b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
@@ -11,7 +11,8 @@ import { Role,
          mostPrivilegedRole,
          messagesByRole }
        from '../../../../../services/Grant/Role'
-import WithOSMUserSearch from '../../../HOCs/WithOSMUserSearch/WithOSMUserSearch'
+import WithOSMUserSearch from '../../../../HOCs/WithOSMUserSearch/WithOSMUserSearch'
+import WithTeamSearch from '../../../../HOCs/WithTeamSearch/WithTeamSearch'
 import AsManager from '../../../../../interactions/User/AsManager'
 import AsAvatarUser from '../../../../../interactions/User/AsAvatarUser'
 import BusySpinner from '../../../../BusySpinner/BusySpinner'
@@ -29,14 +30,16 @@ const descriptor = {
 }
 
 const ChooseOSMUser = WithOSMUserSearch(AutosuggestTextBox)
+const ChooseTeam = WithTeamSearch(AutosuggestTextBox)
 
 export default class ProjectManagersWidget extends Component {
   state = {
     loadingManagers: true,
     updatingManagers: [],
-    addManagerUsername: '',
-    addManagerOSMUser: null,
+    addManagerName: '',
+    addManagerObject: null,
     addingManager: false,
+    choosingTeam: false,
   }
 
   componentDidMount() {
@@ -45,32 +48,52 @@ export default class ProjectManagersWidget extends Component {
     )
   }
 
-  updateManagerRole = (managerOsmId, role) => {
+  switchManagerType = newType => {
+    this.setState({
+      addManagerName: '',
+      addManagerObject: null,
+      choosingTeam: newType === 'team',
+    })
+  }
+
+  updateManagerRole = (manager, role) => {
     if (role === 'remove') {
-      this.removeManager(managerOsmId)
+      this.removeManager(manager)
       return
     }
 
+    const isTeam = _isFinite(manager.groupType)
+    const managerId = isTeam ? manager.id : manager.osmId
     this.setState({
-      updatingManagers: this.state.updatingManagers.concat([managerOsmId])
+      updatingManagers: this.state.updatingManagers.concat([managerId])
     })
 
-    this.props.setProjectManagerRole(
-      this.props.project.id, managerOsmId, true, role
-    ).then(() => this.setState({
-      updatingManagers: _without(this.state.updatingManagers, managerOsmId)
+    const updateManager =
+      isTeam ?
+      this.props.setTeamProjectRole(this.props.project.id, managerId, role) :
+      this.props.setProjectManagerRole(
+        this.props.project.id, manager.osmId, true, role
+      )
+
+    updateManager.then(() => this.setState({
+      updatingManagers: _without(this.state.updatingManagers, managerId)
     }))
   }
 
-  removeManager = (managerOsmId) => {
+  removeManager = (manager) => {
+    const isTeam = _isFinite(manager.groupType)
+    const managerId = isTeam ? manager.id : manager.osmId
     this.setState({
-      updatingManagers: this.state.updatingManagers.concat([managerOsmId])
+      updatingManagers: this.state.updatingManagers.concat([managerId])
     })
 
-    this.props.removeProjectManager(
-      this.props.project.id, managerOsmId, true
-    ).then(() => this.setState({
-      updatingManagers: _without(this.state.updatingManagers, managerOsmId)
+    const removeManager =
+      isTeam ?
+      this.props.removeTeamFromProject(this.props.project.id, manager.id) :
+      this.props.removeProjectManager(this.props.project.id, manager.osmId, true)
+
+    removeManager.then(() => this.setState({
+      updatingManagers: _without(this.state.updatingManagers, managerId)
     }))
   }
 
@@ -80,12 +103,19 @@ export default class ProjectManagersWidget extends Component {
     }
 
     this.setState({addingManager: true})
+    const addManager = this.state.choosingTeam ?
+      this.props.setTeamProjectRole(
+        this.props.project.id,
+        this.state.addManagerObject.id,
+        role
+      ) :
+      this.props.addProjectManager(
+        this.props.project.id, this.state.addManagerName, role
+      )
 
-    this.props.addProjectManager(
-      this.props.project.id, this.state.addManagerUsername, role
-    ).then(() => this.setState({
-      addManagerUsername: '',
-      addManagerOSMUser: null,
+    addManager.then(() => this.setState({
+      addManagerName: '',
+      addManagerObject: null,
       addingManager: false,
     }))
   }
@@ -112,60 +142,83 @@ export default class ProjectManagersWidget extends Component {
     ]
 
     const adminManagers =
-      _filter(this.props.project.managers,
+      _filter(this.props.project.managers.concat(this.props.project.teamManagers),
               manager => mostPrivilegedRole(manager.roles) === Role.admin)
 
-    let managers = _map(this.props.project.managers, manager => {
-      const managerRole = mostPrivilegedRole(manager.roles)
-      const isLastAdmin = managerRole === Role.admin && adminManagers.length < 2
-
-      // Add remove-manager option to dropdown if appropriate
-      const dropdownOptions =
-        user.canAdministrateProject(this.props.project) && !isLastAdmin ?
-        roleOptions.concat([
-          <option key="remove" value="remove">
-            {this.props.intl.formatMessage(messages.removeManagerLabel)}
-          </option>
-        ]) :
-        roleOptions
-
-      return (
-        <div key={manager.osmId} className="mr-flex mr-items-center mr-pr-4 mr-mt-4">
-          <div className="mr-flex-grow-0 mr-mr-4">
-            <figure className="mr-w-8 mr-h-8">
-              <img
-                src={AsAvatarUser(manager).profilePic(100)}
-                alt=""
-                className="mr-rounded-full"
-              />
-            </figure>
-          </div>
+    let managers = _map(
+      this.props.project.managers.concat(this.props.project.teamManagers),
+      manager => {
+        const isTeam = _isFinite(manager.groupType)
+        const managerRole = mostPrivilegedRole(manager.roles)
+        const isLastAdmin = managerRole === Role.admin && adminManagers.length < 2
 
-          <div className="mr-flex-grow-0 mr-links-green-lighter mr-mr-2">
-            <Link to={`/user/metrics/${manager.userId}`}>
-              {manager.displayName}
-            </Link>
-          </div>
+        // Add remove-manager option to dropdown if appropriate
+        const dropdownOptions =
+          user.canAdministrateProject(this.props.project) && !isLastAdmin ?
+          roleOptions.concat([
+            <option key="remove" value="remove">
+              {this.props.intl.formatMessage(messages.removeManagerLabel)}
+            </option>
+          ]) :
+          roleOptions
 
-          <div className="mr-flex-grow mr-border-b mr-border-white-15 mr-mr-4" />
+        return (
+          <div
+            key={isTeam ? `team-${manager.id}` : `user-${manager.osmId}`}
+            className="mr-flex mr-items-center mr-pr-4 mr-mt-4"
+          >
+            {isTeam ?
+             <React.Fragment>
+               <div className="mr-flex-grow-0 mr-mr-4">
+                 <span className="mr-text-pink mr-text-xs mr-uppercase">
+                   <FormattedMessage {...messages.teamIndicator} />
+                 </span>
+               </div>
+               <div className="mr-flex-grow-0 mr-links-green-lighter mr-mr-2">
+                {manager.name}
+               </div>
+             </React.Fragment> :
+             <React.Fragment>
+               <div className="mr-flex-grow-0 mr-mr-4">
+                 <figure className="mr-w-8 mr-h-8">
+                   <img
+                     src={AsAvatarUser(manager).profilePic(100)}
+                     alt=""
+                     className="mr-rounded-full"
+                   />
+                 </figure>
+               </div>
+               <div className="mr-flex-grow-0 mr-links-green-lighter mr-mr-2">
+                 <Link to={`/user/metrics/${manager.userId}`}>
+                   {manager.displayName}
+                 </Link>
+               </div>
+             </React.Fragment>
+            }
 
-          <div className="mr-flex-grow-0">
-            {this.state.updatingManagers.indexOf(manager.osmId) !== -1 && <BusySpinner />}
+            <div className="mr-flex-grow mr-border-b mr-border-white-15 mr-mr-4" />
 
-            {isLastAdmin || !user.canAdministrateProject(this.props.project) ?
-             <FormattedMessage {...messagesByRole[managerRole]} /> :
-             <select
-               value={managerRole}
-               onChange={e => this.updateManagerRole(manager.osmId, e.target.value)}
-               className="mr-select mr-py-1"
-             >
-               {dropdownOptions}
-             </select>
-            }
+            <div className="mr-flex-grow-0">
+              {this.state.updatingManagers.indexOf(isTeam ? manager.id : manager.osmId) !== -1 ?
+               <BusySpinner /> :
+               <React.Fragment>
+                 {isLastAdmin || !user.canAdministrateProject(this.props.project) ?
+                  <FormattedMessage {...messagesByRole[managerRole]} /> :
+                  <select
+                    value={managerRole}
+                    onChange={e => this.updateManagerRole(manager, e.target.value)}
+                    className="mr-select mr-py-1"
+                  >
+                    {dropdownOptions}
+                  </select>
+                 }
+               </React.Fragment>
+              }
+            </div>
           </div>
-        </div>
-      )
-    })
+        )
+      }
+    )
 
     if (managers.length === 0) {
       managers = (
@@ -183,22 +236,51 @@ export default class ProjectManagersWidget extends Component {
 
         <div className="mr-flex mr-justify-between">
           <div className="mr-mr-4 mr-flex-grow">
-            <ChooseOSMUser
-              inputValue={this.state.addManagerUsername}
-              inputClassName="mr-py-2 mr-px-4 mr-border-none mr-placeholder-white-50 mr-text-white mr-rounded mr-bg-black-15 mr-shadow-inner"
-              selectedItem={this.state.addManagerOSMUser}
-              onInputValueChange={username => this.setState({
-                addManagerUsername: username
-              })}
-              onChange={osmUser => this.setState({
-                addManagerOSMUser: osmUser
-              })}
-              placeholder={this.props.intl.formatMessage(messages.osmUsername)}
-              fixedMenu
-            />
+            {this.state.choosingTeam ?
+             <ChooseTeam
+               inputValue={this.state.addManagerName}
+               inputClassName="mr-py-2 mr-px-4 mr-border-none mr-placeholder-white-50 mr-text-white mr-rounded mr-bg-black-15 mr-shadow-inner"
+               selectedItem={this.state.addManagerObject}
+               onInputValueChange={username => this.setState({
+                 addManagerName: username
+               })}
+               onChange={osmUser => this.setState({
+                 addManagerObject: osmUser
+               })}
+               placeholder={this.props.intl.formatMessage(messages.teamNamePlaceholder)}
+               fixedMenu
+             /> :
+             <ChooseOSMUser
+               inputValue={this.state.addManagerName}
+               inputClassName="mr-py-2 mr-px-4 mr-border-none mr-placeholder-white-50 mr-text-white mr-rounded mr-bg-black-15 mr-shadow-inner"
+               selectedItem={this.state.addManagerObject}
+               onInputValueChange={username => this.setState({
+                 addManagerName: username
+               })}
+               onChange={osmUser => this.setState({
+                 addManagerObject: osmUser
+               })}
+               placeholder={this.props.intl.formatMessage(messages.osmUsernamePlaceholder)}
+               fixedMenu
+             />
+            }
           </div>
+          {!this.state.addingManager && !this.state.addManagerObject &&
+           <select
+             className="mr-select mr-flex-grow-0 mr-min-w-12"
+             value={this.state.choosingTeam ? 'team' : 'user'}
+             onChange={e => this.switchManagerType(e.target.value)}
+           >
+             <option key='user' value='user'>
+               {this.props.intl.formatMessage(messages.userOption)}
+             </option>
+             <option key='team' value='team'>
+               {this.props.intl.formatMessage(messages.teamOption)}
+             </option>
+           </select>
+          }
           {this.state.addingManager && <BusySpinner />}
-          {!this.state.addingManager && this.state.addManagerOSMUser &&
+          {!this.state.addingManager && this.state.addManagerObject &&
            <select
              onChange={e => this.addManager(e.target.value)}
              className="mr-flex-grow-0 mr-min-w-30 mr-select"
diff --git a/src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js b/src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js
similarity index 97%
rename from src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js
rename to src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js
index d74b9505..a12f7cf5 100644
--- a/src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js
+++ b/src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js
@@ -3,7 +3,7 @@ import _isEmpty from 'lodash/isEmpty'
 import _filter from 'lodash/filter'
 import _startsWith from 'lodash/startsWith'
 import _debounce from 'lodash/debounce'
-import { findUser } from '../../../../services/User/User'
+import { findUser } from '../../../services/User/User'
 
 /**
  * WithOSMUserSearch provides a findUser function to the wrapped component that allows
diff --git a/src/components/HOCs/WithTeamSearch/WithTeamSearch.js b/src/components/HOCs/WithTeamSearch/WithTeamSearch.js
new file mode 100644
index 00000000..ab83ecca
--- /dev/null
+++ b/src/components/HOCs/WithTeamSearch/WithTeamSearch.js
@@ -0,0 +1,70 @@
+import React, { Component } from 'react'
+import _isEmpty from 'lodash/isEmpty'
+import _filter from 'lodash/filter'
+import _startsWith from 'lodash/startsWith'
+import _debounce from 'lodash/debounce'
+import { findTeam } from '../../../services/Team/Team'
+
+/**
+ * WithTeamSearch provides a findTeam function to the wrapped component that
+ * allows it to initiate searches for MapRoulette teams by name or name
+ * fragment
+ *
+ * @author [Neil Rotstan](https://github.com/nrotstan)
+ */
+const WithTeamSearch = function(WrappedComponent) {
+  return class extends Component {
+    state = {
+      isSearchingTeams: false,
+      teamResults: [],
+    }
+
+    /**
+     * @private
+     */
+    performSearch = _debounce(teamName => {
+      this.setState({isSearchingTeams: true})
+
+      findTeam(teamName).then(results => {
+        this.setState({isSearchingTeams: false, teamResults: results})
+      })
+    }, 1000, {leading: true})
+
+    /**
+     * Initiates search for team with the given name or name fragment
+     */
+    searchTeam = teamName => {
+      if (_isEmpty(teamName)) {
+        this.setState({teamResults: []})
+        return
+      }
+
+      // Start off by filtering our existing search results so that we don't continue
+      // to show results that no longer match the new team name
+      this.setState({
+        isSearchingTeams: true,
+        teamResults: _filter(this.state.teamResults, result =>
+          _startsWith(result.name.toLowerCase(), teamName.toLowerCase()))
+      })
+
+      this.performSearch(teamName)
+    }
+
+    teamKey = team => team.id
+
+    teamLabel = team => team.name
+
+    render() {
+      return (
+        <WrappedComponent isSearching={this.state.isSearchingTeams}
+                          searchResults={this.state.teamResults}
+                          search={this.searchTeam}
+                          resultKey={this.teamKey}
+                          resultLabel={this.teamLabel}
+                          {...this.props} />
+      )
+    }
+  }
+}
+
+export default WithTeamSearch
diff --git a/src/components/QuickWidget/Messages.js b/src/components/QuickWidget/Messages.js
new file mode 100644
index 00000000..fec83f77
--- /dev/null
+++ b/src/components/QuickWidget/Messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with QuickWidget
+ */
+export default defineMessages({
+  widgetFailure: {
+    id: "QuickWidget.failedToLoad",
+    defaultMessage: "Widget Failed",
+  },
+})
diff --git a/src/components/QuickWidget/QuickWidget.js b/src/components/QuickWidget/QuickWidget.js
index 28e5e1c4..b667fac3 100644
--- a/src/components/QuickWidget/QuickWidget.js
+++ b/src/components/QuickWidget/QuickWidget.js
@@ -1,6 +1,8 @@
 import React, { Component } from 'react'
 import PropTypes from 'prop-types'
+import { FormattedMessage } from 'react-intl'
 import classNames from 'classnames'
+import messages from './Messages'
 
 /**
  * QuickWidget makes creation of widgets easier by encapsulating the needed
@@ -14,11 +16,28 @@ import classNames from 'classnames'
  * @author [Neil Rotstan](https://github.com/nrotstan)
  */
 export default class QuickWidget extends Component {
+  state = {
+    error: false,
+  }
+
+  componentDidCatch(error, info) {
+    console.log(error)
+    this.setState({error: true})
+  }
+
   render() {
     if (this.props.widgetHidden) {
       return null
     }
 
+    if (this.state.error) {
+      return (
+        <div className="mr-text-lg mr-text-red-light mr-flex mr-justify-center mr-items-center">
+          <FormattedMessage {...messages.widgetFailure} />
+        </div>
+      )
+    }
+
     return (
       <section className={classNames("mr-flex mr-flex-col mr-h-full", this.props.className, {"mr-mb-4": this.props.isEditing})}>
         {this.props.isEditing && !this.props.permanent && !this.props.widgetPermanent &&
diff --git a/src/components/RolePicker/Messages.js b/src/components/RolePicker/Messages.js
new file mode 100644
index 00000000..ce13208e
--- /dev/null
+++ b/src/components/RolePicker/Messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with RolePicker
+ */
+export default defineMessages({
+  chooseRole: {
+    id: "RolePicker.chooseRole.label",
+    defaultMessage: "Choose Role",
+  },
+})
diff --git a/src/components/RolePicker/RolePicker.js b/src/components/RolePicker/RolePicker.js
new file mode 100644
index 00000000..d8f52a16
--- /dev/null
+++ b/src/components/RolePicker/RolePicker.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Role, messagesByRole } from '../../services/Grant/Role'
+import messages from './Messages'
+
+const RolePicker = props => {
+  const roleOptions = [
+    <option key={Role.read} value={Role.read}>
+      {props.intl.formatMessage(messagesByRole[Role.read])}
+    </option>,
+
+    <option key={Role.write} value={Role.write}>
+      {props.intl.formatMessage(messagesByRole[Role.write])}
+    </option>,
+
+    <option key={Role.admin} value={Role.admin}>
+      {props.intl.formatMessage(messagesByRole[Role.admin])}
+    </option>,
+  ]
+
+  return (
+    <select
+      value={props.role}
+      onChange={e => props.pickRole(e.target.value)}
+      className="mr-flex-grow-0 mr-min-w-30 mr-select"
+    >
+      {[
+        <option key='none' value=''>
+          {props.intl.formatMessage(messages.chooseRole)}
+        </option>
+      ].concat(roleOptions)}
+    </select>
+  )
+}
+
+RolePicker.propTypes = {
+  pickRole: PropTypes.func.isRequired,
+}
+
+export default RolePicker
diff --git a/src/components/Teams/AddTeamMember/AddTeamMember.js b/src/components/Teams/AddTeamMember/AddTeamMember.js
new file mode 100644
index 00000000..a0177a42
--- /dev/null
+++ b/src/components/Teams/AddTeamMember/AddTeamMember.js
@@ -0,0 +1,61 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import { injectIntl } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import WithOSMUserSearch from '../../HOCs/WithOSMUserSearch/WithOSMUserSearch'
+import AutosuggestTextBox from '../../AutosuggestTextBox/AutosuggestTextBox'
+import RolePicker from '../../RolePicker/RolePicker'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import { INVITE_USER } from '../TeamQueries'
+import messages from './Messages'
+
+const ChooseOSMUser = WithOSMUserSearch(AutosuggestTextBox)
+
+/**
+ * Control for inviting a user to join a team
+ */
+const AddTeamMember = props => {
+  const [username, setUsername] = useState("")
+  const [selectedUser, setSelectedUser] = useState(null)
+  const [inviteTeamUser, { loading: isSaving }] = useMutation(INVITE_USER)
+
+  const inviteSelectedUser = role => {
+    inviteTeamUser({
+      variables: { teamId: props.team.id, userId: selectedUser.id, role: parseInt(role) },
+      refetchQueries: ['TeamUsers'],
+    })
+    .catch(error => {
+        props.addErrorWithDetails(AppErrors.team.failure, error.message)
+    })
+    setSelectedUser(null)
+    setUsername("")
+  }
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  return (
+    <div className="mr-flex mr-justify-between">
+      <div className="mr-mr-4 mr-flex-grow">
+        <ChooseOSMUser
+          inputValue={username}
+          inputClassName="mr-py-2 mr-px-4 mr-border-none mr-placeholder-white-50 mr-text-white mr-rounded mr-bg-black-15 mr-shadow-inner"
+          selectedItem={selectedUser}
+          onInputValueChange={username => setUsername(username)}
+          onChange={osmUser => setSelectedUser(osmUser)}
+          placeholder={props.intl.formatMessage(messages.osmUsername)}
+          fixedMenu
+        />
+      </div>
+      {selectedUser && <RolePicker {...props} pickRole={inviteSelectedUser} />}
+    </div>
+  )
+}
+
+AddTeamMember.propTypes = {
+  team: PropTypes.object.isRequired,
+}
+
+export default injectIntl(AddTeamMember)
diff --git a/src/components/Teams/AddTeamMember/Messages.js b/src/components/Teams/AddTeamMember/Messages.js
new file mode 100644
index 00000000..f4a9dda1
--- /dev/null
+++ b/src/components/Teams/AddTeamMember/Messages.js
@@ -0,0 +1,17 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with AddTeamMember
+ */
+export default defineMessages({
+  chooseRole: {
+    id: "AddTeamMember.controls.chooseRole.label",
+    defaultMessage: "Choose Role",
+  },
+
+  osmUsername: {
+    id: "AddTeamMember.controls.chooseOSMUser.placeholder",
+    defaultMessage: "OpenStreetMap username"
+  },
+})
+
diff --git a/src/components/Teams/EditTeam/EditTeam.js b/src/components/Teams/EditTeam/EditTeam.js
new file mode 100644
index 00000000..c9df25e9
--- /dev/null
+++ b/src/components/Teams/EditTeam/EditTeam.js
@@ -0,0 +1,79 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import { useMutation } from '@apollo/client'
+import Form from 'react-jsonschema-form'
+import { FormattedMessage } from 'react-intl'
+import _isFinite from 'lodash/isFinite'
+import AppErrors from '../../../services/Error/AppErrors'
+import { jsSchema, uiSchema } from './TeamSchema'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import { CREATE_TEAM, UPDATE_TEAM } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Displays a form for creating or editing team fields
+ */
+export const EditTeam = props => {
+  const [teamFields, setTeamFields] = useState({})
+  const [createTeam, { loading: isCreating }] = useMutation(CREATE_TEAM)
+  const [updateTeam, { loading: isUpdating }] = useMutation(UPDATE_TEAM)
+  const isSaving = isCreating || isUpdating
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  return (
+    <Form
+      schema={jsSchema(props.intl)}
+      uiSchema={uiSchema(props.intl)}
+      className="form"
+      liveValidate
+      noHtml5Validate
+      showErrorList={false}
+      formData={Object.assign({}, props.team, teamFields)}
+      onChange={({formData}) => setTeamFields(formData)}
+    >
+      <div className="mr-flex mr-justify-between mr-items-center mr-mt-8">
+        <button
+          type="button"
+          className="mr-button mr-button--white"
+          onClick={props.finish}
+        >
+          <FormattedMessage {...messages.cancelLabel} />
+        </button>
+
+        {isSaving ?
+         <BusySpinner inline /> :
+         <button
+           type="button"
+           className="mr-button mr-button--green-lighter mr-ml-4"
+           onClick={() => {
+             if (_isFinite(teamFields.id)) {
+               updateTeam({variables: teamFields})
+             }
+             else {
+              createTeam({
+                variables: teamFields,
+                refetchQueries: ['MyTeams'],
+              })
+              .catch(error => {
+                  props.addErrorWithDetails(AppErrors.team.failure, error.message)
+              })
+             }
+             props.finish()
+           }}
+         >
+           <FormattedMessage {...messages.saveLabel} />
+         </button>
+        }
+      </div>
+    </Form>
+  )
+}
+
+EditTeam.propTypes = {
+  finish: PropTypes.func.isRequired,
+}
+
+export default EditTeam
diff --git a/src/components/Teams/EditTeam/Messages.js b/src/components/Teams/EditTeam/Messages.js
new file mode 100644
index 00000000..cd768b6a
--- /dev/null
+++ b/src/components/Teams/EditTeam/Messages.js
@@ -0,0 +1,36 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with Teams
+ */
+export default defineMessages({
+  nameLabel: {
+    id: "Team.name.label",
+    defaultMessage: "Name",
+  },
+
+  nameDescription: {
+    id: "Team.name.description",
+    defaultMessage: "The unique name of the team",
+  },
+
+  descriptionLabel: {
+    id: "Team.description.label",
+    defaultMessage: "Description",
+  },
+
+  descriptionDescription: {
+    id: "Team.description.description",
+    defaultMessage: "A brief description of the team",
+  },
+
+  saveLabel: {
+    id: "Team.controls.save.label",
+    defaultMessage: "Save",
+  },
+
+  cancelLabel: {
+    id: "Team.controls.cancel.label",
+    defaultMessage: "Cancel",
+  },
+})
diff --git a/src/components/Teams/EditTeam/TeamSchema.js b/src/components/Teams/EditTeam/TeamSchema.js
new file mode 100644
index 00000000..488f1958
--- /dev/null
+++ b/src/components/Teams/EditTeam/TeamSchema.js
@@ -0,0 +1,42 @@
+import messages from './Messages'
+
+/**
+ * Generates a JSON Schema describing Team fields for consumption by
+ * react-jsonschema-form.
+ *
+ * @param intl - intl instance from react-intl
+ *
+ * @see See http://json-schema.org
+ * @see See https://github.com/mozilla-services/react-jsonschema-form
+ *
+ * @author [Neil Rotstan](https://github.com/nrotstan)
+ */
+export const jsSchema = intl => ({
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  type: "object",
+  properties: {
+    name: {
+      title: intl.formatMessage(messages.nameLabel),
+      type: "string",
+    },
+    description: {
+      title: intl.formatMessage(messages.descriptionLabel),
+      type: "string",
+    },
+  },
+})
+
+/**
+ * uiSchema configuration to assist react-jsonschema-form in determining
+ * how to render the schema fields.
+ *
+ * @see See https://github.com/mozilla-services/react-jsonschema-form
+ */
+export const uiSchema = intl => ({
+  name: {
+    "ui:help": intl.formatMessage(messages.nameDescription),
+  },
+  description: {
+    "ui:help": intl.formatMessage(messages.descriptionDescription),
+  },
+})
diff --git a/src/components/Teams/MemberControls/AcceptInviteControlItem.js b/src/components/Teams/MemberControls/AcceptInviteControlItem.js
new file mode 100644
index 00000000..6208e9e1
--- /dev/null
+++ b/src/components/Teams/MemberControls/AcceptInviteControlItem.js
@@ -0,0 +1,55 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { FormattedMessage } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import { ACCEPT_INVITE } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Control item for accepting an invitation to join a team
+ */
+const AcceptInviteControlItem = props => {
+  const [acceptInvite, { loading: isSaving }] = useMutation(ACCEPT_INVITE)
+
+  // The team member needs have an invitation and be the current user
+  if (!props.teamMember.isInvited() || !props.teamMember.isUser(props.user)) {
+    return null
+  }
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  return (
+    <li className={props.className}>
+      { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+      <a
+        onClick={() => {
+          acceptInvite({
+            variables: { teamId: props.teamMember.team.id },
+            refetchQueries: props.refetchQueries,
+          })
+          .catch(error => {
+            props.addErrorWithDetails(AppErrors.team.failure, error.message)
+          })
+        }}
+      >
+        <FormattedMessage {...messages.acceptInviteLabel} />
+      </a>
+    </li>
+  )
+}
+
+AcceptInviteControlItem.propTypes = {
+  user: PropTypes.object.isRequired,
+  teamMember: PropTypes.object.isRequired,
+  refetchQueries: PropTypes.array,
+}
+
+AcceptInviteControlItem.defaultProps = {
+  refetchQueries: [],
+}
+
+export default AcceptInviteControlItem
diff --git a/src/components/Teams/MemberControls/DeclineInviteControlItem.js b/src/components/Teams/MemberControls/DeclineInviteControlItem.js
new file mode 100644
index 00000000..fee30f02
--- /dev/null
+++ b/src/components/Teams/MemberControls/DeclineInviteControlItem.js
@@ -0,0 +1,52 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { FormattedMessage } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import { DECLINE_INVITE } from '../TeamQueries'
+import messages from './Messages'
+
+const DeclineInviteControlItem = props => {
+  const [declineInvite, { loading: isSaving }] = useMutation(DECLINE_INVITE)
+
+  // The team member needs have an invitation and be the current user
+  if (!props.teamMember.isInvited() || !props.teamMember.isUser(props.user)) {
+    return null
+  }
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  return (
+    <li className={props.className}>
+      { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+      <a
+        onClick={() => {
+          declineInvite({
+            variables: { teamId: props.teamMember.team.id },
+            refetchQueries: props.refetchQueries,
+          })
+          .catch(error => {
+            props.addErrorWithDetails(AppErrors.team.failure, error.message)
+          })
+        }}
+      >
+        <FormattedMessage {...messages.declineInviteLabel} />
+      </a>
+    </li>
+  )
+}
+
+DeclineInviteControlItem.propTypes = {
+  user: PropTypes.object.isRequired,
+  teamMember: PropTypes.object.isRequired,
+  refetchQueries: PropTypes.array,
+}
+
+DeclineInviteControlItem.defaultProps = {
+  refetchQueries: [],
+}
+
+export default DeclineInviteControlItem
diff --git a/src/components/Teams/MemberControls/MemberControls.js b/src/components/Teams/MemberControls/MemberControls.js
new file mode 100644
index 00000000..2147dee3
--- /dev/null
+++ b/src/components/Teams/MemberControls/MemberControls.js
@@ -0,0 +1,48 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { MY_TEAMS } from '../TeamQueries'
+import RemoveMemberControlItem from './RemoveMemberControlItem'
+import AcceptInviteControlItem from './AcceptInviteControlItem'
+import DeclineInviteControlItem from './DeclineInviteControlItem'
+
+/**
+ * Menu of controls for a team member
+ */
+export const MemberControls = props => {
+  return (
+    <ul className="mr-links-green-lighter">
+      <AcceptInviteControlItem
+        {...props}
+        key="accept-invite"
+        className="mr-my-1"
+        refetchQueries={['TeamUsers']}
+      />
+      <DeclineInviteControlItem
+        {...props}
+        key="decline-invite"
+        className="mr-my-1"
+        refetchQueries={[
+          'TeamUsers',
+          {query: MY_TEAMS, variables: { userId: props.user.id }}
+        ]}
+      />
+      <RemoveMemberControlItem
+        {...props}
+        key="leave-team"
+        className="mr-my-1"
+        refetchQueries={[
+          'TeamUsers',
+          {query: MY_TEAMS, variables: { userId: props.user.id }}
+        ]}
+      />
+    </ul>
+  )
+}
+
+MemberControls.propTypes = {
+  user: PropTypes.object.isRequired,
+  team: PropTypes.object.isRequired,
+  teamMember: PropTypes.object.isRequired,
+}
+
+export default MemberControls
diff --git a/src/components/Teams/MemberControls/Messages.js b/src/components/Teams/MemberControls/Messages.js
new file mode 100644
index 00000000..0efd5f5b
--- /dev/null
+++ b/src/components/Teams/MemberControls/Messages.js
@@ -0,0 +1,26 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with MemberControls
+ */
+export default defineMessages({
+  acceptInviteLabel: {
+    id: "Team.member.controls.acceptInvite.label",
+    defaultMessage: "Join Team",
+  },
+
+  declineInviteLabel: {
+    id: "Team.member.controls.declineInvite.label",
+    defaultMessage: "Decline Invite",
+  },
+
+  removeMemberLabel: {
+    id: "Team.member.controls.delete.label",
+    defaultMessage: "Remove User",
+  },
+
+  leaveTeamLabel: {
+    id: "Team.member.controls.leave.label",
+    defaultMessage: "Leave Team",
+  }
+})
diff --git a/src/components/Teams/MemberControls/RemoveMemberControlItem.js b/src/components/Teams/MemberControls/RemoveMemberControlItem.js
new file mode 100644
index 00000000..0769814f
--- /dev/null
+++ b/src/components/Teams/MemberControls/RemoveMemberControlItem.js
@@ -0,0 +1,71 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { FormattedMessage } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import ConfirmAction from '../../ConfirmAction/ConfirmAction'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import { REMOVE_USER } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Control item for removing a member from a team (either as an admin, or
+ * leaving a team as the current user)
+ */
+const RemoveMemberControlItem = props => {
+  const [removeTeamUser, { loading: isSaving }] = useMutation(REMOVE_USER)
+
+  // Only the member itself or a team admin can remove members, and members can
+  // only remove themselves if they are active on the team (if they are merely
+  // invited, they need to decline the invitation instead)
+  const isAdmin = props.userTeamMember && props.userTeamMember.isTeamAdmin()
+  if (!isAdmin) {
+    if (!props.teamMember.isUser(props.user) || !props.teamMember.isActive()) {
+      return null
+    }
+  }
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  return (
+    <li className={props.className}>
+      <ConfirmAction>
+        { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+        <a
+          onClick={() => {
+            removeTeamUser({
+              variables: {
+                teamId: props.teamMember.team.id,
+                userId: props.teamMember.userId
+              },
+              refetchQueries: props.refetchQueries,
+            })
+            .catch(error => {
+              props.addErrorWithDetails(AppErrors.team.failure, error.message)
+            })
+          }}
+        >
+          {props.teamMember.isUser(props.user) ?
+           <FormattedMessage {...messages.leaveTeamLabel} /> :
+           <FormattedMessage {...messages.removeMemberLabel} />
+          }
+        </a>
+      </ConfirmAction>
+    </li>
+  )
+}
+
+RemoveMemberControlItem.propTypes = {
+  user: PropTypes.object.isRequired,
+  userTeamMember: PropTypes.object,
+  teamMember: PropTypes.object.isRequired,
+  refetchQueries: PropTypes.array,
+}
+
+RemoveMemberControlItem.defaultProps = {
+  refetchQueries: [],
+}
+
+export default RemoveMemberControlItem
diff --git a/src/components/Teams/MemberItem/MemberItem.js b/src/components/Teams/MemberItem/MemberItem.js
new file mode 100644
index 00000000..b1b5cba6
--- /dev/null
+++ b/src/components/Teams/MemberItem/MemberItem.js
@@ -0,0 +1,96 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Link } from 'react-router-dom'
+import { FormattedMessage } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import AsTeamMember from '../../../interactions/TeamMember/AsTeamMember'
+import RolePicker from '../../RolePicker/RolePicker'
+import Dropdown from '../../Dropdown/Dropdown'
+import SvgSymbol from '../../SvgSymbol/SvgSymbol'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import MemberControls from '../MemberControls/MemberControls'
+import { UPDATE_ROLE } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Lists a team member along with member controls
+ */
+const MemberItem = props => {
+  const [updateRole, { loading: isLoading }] = useMutation(UPDATE_ROLE)
+
+  if (isLoading) {
+    return <BusySpinner />
+  }
+
+  const teamMember = AsTeamMember(props.teamUser)
+  return (
+    <li className="mr-my-1">
+      <div className="mr-flex mr-justify-between">
+        <Link to={`/user/metrics/${teamMember.userId}`}>
+          {teamMember.name}
+          {teamMember.isUser(props.user) &&
+           <span className="mr-ml-2 mr-text-mango">
+             <FormattedMessage {...messages.youLabel} />
+           </span>
+          }
+        </Link>
+
+        <div className="mr-flex mr-justify-end">
+          <div className="mr-mr-4">
+            {(props.userTeamMember.isTeamAdmin() && teamMember.isActive()) ?
+             <RolePicker
+               {...props}
+               role={teamMember.highestRole()}
+               pickRole={role =>
+                updateRole({
+                  variables: {
+                    teamId: teamMember.team.id,
+                    userId: teamMember.userId,
+                    role: parseInt(role),
+                  },
+                  refetchQueries: ['TeamUsers'],
+                })
+                .catch(error => {
+                  props.addErrorWithDetails(AppErrors.team.failure, error.message)
+                })
+               }
+             /> :
+             <FormattedMessage {...teamMember.roleDescription()} />
+            }
+          </div>
+          <div className="mr-min-w-6">
+            {(props.userTeamMember.isTeamAdmin() || teamMember.isUser(props.user)) &&
+             <Dropdown
+               className="mr-dropdown--right"
+               dropdownButton={dropdown => (
+                 <button
+                   onClick={dropdown.toggleDropdownVisible}
+                   className="mr-flex mr-items-center mr-text-white"
+                 >
+                   <SvgSymbol
+                     sym="navigation-more-icon"
+                     viewBox="0 0 20 20"
+                     className="mr-fill-current mr-w-5 mr-h-5"
+                   />
+                 </button>
+               )}
+               dropdownContent={dropdown =>
+                 <MemberControls {...props} teamMember={teamMember} />
+               }
+             />
+            }
+          </div>
+        </div>
+      </div>
+    </li>
+  )
+}
+
+MemberItem.propTypes = {
+  user: PropTypes.object.isRequired,
+  userTeamMember: PropTypes.object.isRequired,
+  teamUser: PropTypes.object.isRequired,
+}
+
+export default MemberItem
diff --git a/src/components/Teams/MemberItem/Messages.js b/src/components/Teams/MemberItem/Messages.js
new file mode 100644
index 00000000..7e1d2fda
--- /dev/null
+++ b/src/components/Teams/MemberItem/Messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with MemberItem
+ */
+export default defineMessages({
+  youLabel: {
+    id: "Team.members.indicator.you.label",
+    defaultMessage: "(you)",
+  },
+})
diff --git a/src/components/Teams/MyTeams/Messages.js b/src/components/Teams/MyTeams/Messages.js
new file mode 100644
index 00000000..12631a5b
--- /dev/null
+++ b/src/components/Teams/MyTeams/Messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with MyTeams
+ */
+export default defineMessages({
+  noTeams: {
+    id: "Team.noTeams",
+    defaultMessage: "You are not a member of any teams",
+  },
+})
diff --git a/src/components/Teams/MyTeams/MyTeams.js b/src/components/Teams/MyTeams/MyTeams.js
new file mode 100644
index 00000000..ec660c3c
--- /dev/null
+++ b/src/components/Teams/MyTeams/MyTeams.js
@@ -0,0 +1,112 @@
+import React, { useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { useQuery } from '@apollo/client'
+import { FormattedMessage } from 'react-intl'
+import _find from 'lodash/find'
+import _throttle from 'lodash/throttle'
+import { subscribeToTeamUpdates, unsubscribeFromTeamUpdates }
+       from '../../../services/Team/Team'
+import AsTeamMember from '../../../interactions/TeamMember/AsTeamMember'
+import Dropdown from '../../Dropdown/Dropdown'
+import SvgSymbol from '../../SvgSymbol/SvgSymbol'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import TeamControls from '../TeamControls/TeamControls'
+import { MY_TEAMS } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Lists all the teams on which the current user is a member
+ */
+export const MyTeams = function(props) {
+  const { loading, error, data, refetch } = useQuery(MY_TEAMS, {
+    variables: { userId: props.user.id }
+  })
+
+  // Refresh the teams when this component updates to avoid stale data. It's
+  // throttled down to one request at most every 2 seconds to avoid rapid
+  // refreshing
+  useEffect(() => refreshTeams(refetch))
+
+  useEffect(() => {
+    subscribeToTeamUpdates(message => {
+      if (message.data.userId === props.user.id ||
+          _find(data.userTeams, {teamId: message.data.teamId})) {
+        refetch()
+      }
+    }, "MyTeams")
+
+    return () => unsubscribeFromTeamUpdates("MyTeams")
+  })
+
+  if (error) {
+    throw error
+  }
+
+  if (loading) {
+    return <BusySpinner />
+  }
+
+  const teamItems = data.userTeams.map(teamUser => {
+    const teamMember = AsTeamMember(teamUser)
+    return (
+      <li key={teamMember.team.id} className="mr-h-5 mr-my-2">
+        <div className="mr-flex mr-justify-between">
+          {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+          <a onClick={() => props.viewTeam(teamMember.team)}>
+            {teamMember.team.name}
+          </a>
+
+          <div className="mr-flex mr-justify-end mr-items-center">
+            <div className="mr-mr-4">
+              <FormattedMessage {...teamMember.roleDescription()} />
+            </div>
+
+            <div className="mr-min-w-6 mr-h-5">
+              <Dropdown
+                className="mr-dropdown--right"
+                dropdownButton={dropdown => (
+                  <button
+                    onClick={dropdown.toggleDropdownVisible}
+                    className="mr-flex mr-items-center mr-text-white"
+                  >
+                    <SvgSymbol
+                      sym="navigation-more-icon"
+                      viewBox="0 0 20 20"
+                      className="mr-fill-current mr-w-5 mr-h-5"
+                    />
+                  </button>
+                )}
+                dropdownContent={dropdown =>
+                  <TeamControls {...props} teamMember={teamMember} />
+                }
+              />
+            </div>
+          </div>
+        </div>
+      </li>
+    )
+  })
+
+  return (
+    <div className="mr-flex mr-flex-col mr-justify-between">
+      {teamItems.length === 0 ?
+       <FormattedMessage {...messages.noTeams} /> :
+       <ul className="mr-links-green-lighter">
+         {teamItems}
+       </ul>
+      }
+    </div>
+  )
+}
+
+const refreshTeams = _throttle((refetch) => {
+  refetch()
+}, 2000, {leading: true, trailing: false})
+
+MyTeams.propTypes = {
+  user: PropTypes.object.isRequired,
+  viewTeam: PropTypes.func.isRequired,
+  editTeam: PropTypes.func.isRequired,
+}
+
+export default MyTeams
diff --git a/src/components/Teams/TeamControls/Messages.js b/src/components/Teams/TeamControls/Messages.js
new file mode 100644
index 00000000..e583f15a
--- /dev/null
+++ b/src/components/Teams/TeamControls/Messages.js
@@ -0,0 +1,36 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with TeamControls
+ */
+export default defineMessages({
+  viewTeamLabel: {
+    id: "Team.controls.view.label",
+    defaultMessage: "View Team",
+  },
+
+  editTeamLabel: {
+    id: "Team.controls.edit.label",
+    defaultMessage: "Edit Team",
+  },
+
+  deleteTeamLabel: {
+    id: "Team.controls.delete.label",
+    defaultMessage: "Delete Team",
+  },
+
+  acceptInviteLabel: {
+    id: "Team.controls.acceptInvite.label",
+    defaultMessage: "Join Team",
+  },
+
+  declineInviteLabel: {
+    id: "Team.controls.declineInvite.label",
+    defaultMessage: "Decline Invite",
+  },
+
+  leaveTeamLabel: {
+    id: "Team.controls.leave.label",
+    defaultMessage: "Leave Team",
+  },
+})
diff --git a/src/components/Teams/TeamControls/TeamControls.js b/src/components/Teams/TeamControls/TeamControls.js
new file mode 100644
index 00000000..32af3682
--- /dev/null
+++ b/src/components/Teams/TeamControls/TeamControls.js
@@ -0,0 +1,91 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { FormattedMessage } from 'react-intl'
+import { useMutation } from '@apollo/client'
+import AppErrors from '../../../services/Error/AppErrors'
+import ConfirmAction from '../../ConfirmAction/ConfirmAction'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import RemoveMemberControlItem from '../MemberControls/RemoveMemberControlItem'
+import AcceptInviteControlItem from '../MemberControls/AcceptInviteControlItem'
+import DeclineInviteControlItem from '../MemberControls/DeclineInviteControlItem'
+import { DELETE_TEAM } from '../TeamQueries'
+import messages from './Messages'
+
+/**
+ * Menu of controls for a team
+ */
+export const TeamControls = props => {
+  const [deleteTeam, { loading: isDeleting }] = useMutation(DELETE_TEAM)
+  const isSaving = isDeleting
+
+  if (isSaving) {
+    return <BusySpinner />
+  }
+
+  const isAdmin = props.teamMember.isUser(props.user) && props.teamMember.isTeamAdmin()
+  return (
+    <ul className="mr-links-green-lighter">
+      {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+      <a onClick={() => props.viewTeam(props.teamMember.team)}>
+        <FormattedMessage {...messages.viewTeamLabel} />
+      </a>
+      {isAdmin &&
+       <React.Fragment>
+         <li key="edit-team" className="mr-my-1">
+           { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+           <a onClick={() => props.editTeam(props.teamMember.team)}>
+             <FormattedMessage {...messages.editTeamLabel} />
+           </a>
+         </li>
+
+         <li key="delete-team" className="mr-my-1">
+           <ConfirmAction>
+             { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+             <a
+               onClick={() => {
+                 deleteTeam({
+                   variables: { teamId: props.teamMember.team.id },
+                   refetchQueries: ['MyTeams'],
+                 })
+                 .catch(error => {
+                     props.addErrorWithDetails(AppErrors.team.failure, error.message)
+                 })
+               }}
+             >
+               <FormattedMessage {...messages.deleteTeamLabel} />
+             </a>
+           </ConfirmAction>
+         </li>
+       </React.Fragment>
+      }
+      <AcceptInviteControlItem
+        {...props}
+        key="accept-invite"
+        className="mr-my-1"
+        refetchQueries={['MyTeams']}
+      />
+      <DeclineInviteControlItem
+        {...props}
+        key="decline-invite"
+        className="mr-my-1"
+        refetchQueries={['MyTeams']}
+      />
+      {props.teamMember.isActive() && props.teamMember.isUser(props.user) &&
+       <RemoveMemberControlItem
+         {...props}
+         key="leave-team"
+         className="mr-my-1"
+         refetchQueries={['MyTeams']}
+       />
+      }
+    </ul>
+  )
+}
+
+TeamControls.propTypes = {
+  teamMember: PropTypes.object.isRequired,
+  viewTeam: PropTypes.func.isRequired,
+  editTeam: PropTypes.func.isRequired,
+}
+
+export default TeamControls
diff --git a/src/components/Teams/TeamQueries.js b/src/components/Teams/TeamQueries.js
new file mode 100644
index 00000000..6b9bbdb4
--- /dev/null
+++ b/src/components/Teams/TeamQueries.js
@@ -0,0 +1,100 @@
+import { gql } from '@apollo/client'
+
+export const MY_TEAMS = gql`
+  query MyTeams($userId: Long!) {
+    userTeams(id: $userId) {
+      id
+      userId
+      status
+      teamGrants {
+        id
+        role
+      }
+      team {
+        id
+        name
+        description
+      }
+    }
+  }
+`
+
+export const TEAM_USERS = gql`
+  query TeamUsers($teamId: Long!) {
+    teamUsers(id: $teamId) {
+      id
+      userId
+      name
+      team {
+        id
+      }
+      teamGrants {
+        id
+        role
+      }
+      status
+    }
+  }
+`
+
+export const CREATE_TEAM = gql`
+  mutation CreateTeam($name: String!, $description: String!) {
+    createTeam(name: $name, description: $description) {
+      id
+      name
+      description
+    }
+  }
+`
+
+export const UPDATE_TEAM = gql`
+  mutation UpdateTeam($id: Long!, $name: String, $description: String) {
+    updateTeam(id: $id, name: $name, description: $description) {
+      id
+      name
+      description
+    }
+  }
+`
+
+export const INVITE_USER = gql`
+  mutation InviteUser($teamId: Long!, $userId: Long!, $role: Int!) {
+    inviteTeamUser(id: $teamId, userId: $userId, role: $role) {
+      id
+    }
+  }
+`
+
+export const ACCEPT_INVITE = gql`
+  mutation AcceptInvite($teamId: Long!) {
+    acceptTeamInvite(id: $teamId) {
+      id
+    }
+  }
+`
+
+export const DECLINE_INVITE = gql`
+  mutation DeclineInvite($teamId: Long!) {
+    declineTeamInvite(id: $teamId) 
+  }
+`
+
+export const UPDATE_ROLE = gql`
+  mutation UpdateRole($teamId: Long!, $userId: Long!, $role: Int!) {
+    updateMemberRole(id: $teamId, userId: $userId, role: $role) {
+      id
+    }
+  }
+`
+
+export const REMOVE_USER = gql`
+  mutation RemoveUser($teamId: Long!, $userId: Long!) {
+    removeTeamUser(id: $teamId, userId: $userId)
+  }
+`
+
+export const DELETE_TEAM = gql`
+  mutation DeleteTeam($teamId: Long!) {
+    deleteTeam(id: $teamId) 
+  }
+`
diff --git a/src/components/Teams/ViewTeam/Messages.js b/src/components/Teams/ViewTeam/Messages.js
new file mode 100644
index 00000000..fca7e47b
--- /dev/null
+++ b/src/components/Teams/ViewTeam/Messages.js
@@ -0,0 +1,21 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with ViewTeam
+ */
+export default defineMessages({
+  activeMembersHeader: {
+    id: "Team.activeMembers.header",
+    defaultMessage: "Active Members",
+  },
+
+  invitedMembersHeader: {
+    id: "Team.invitedMembers.header",
+    defaultMessage: "Pending Invitations",
+  },
+
+  addMembersHeader: {
+    id: "Team.addMembers.header",
+    defaultMessage: "Invite New Member",
+  },
+})
diff --git a/src/components/Teams/ViewTeam/ViewTeam.js b/src/components/Teams/ViewTeam/ViewTeam.js
new file mode 100644
index 00000000..46c486b0
--- /dev/null
+++ b/src/components/Teams/ViewTeam/ViewTeam.js
@@ -0,0 +1,103 @@
+import React, { useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { useQuery } from '@apollo/client'
+import { FormattedMessage } from 'react-intl'
+import _find from 'lodash/find'
+import _filter from 'lodash/filter'
+import { subscribeToTeamUpdates, unsubscribeFromTeamUpdates }
+       from '../../../services/Team/Team'
+import AsTeamMember from '../../../interactions/TeamMember/AsTeamMember'
+import BusySpinner from '../../BusySpinner/BusySpinner'
+import AddTeamMember from '../AddTeamMember/AddTeamMember'
+import MemberItem from '../MemberItem/MemberItem'
+import { TEAM_USERS } from '../TeamQueries'
+import messages from './Messages'
+
+export const ViewTeam = props => {
+  const { loading, error, data, refetch } = useQuery(TEAM_USERS, {
+    variables: { teamId: props.team.id }
+  })
+
+  useEffect(() => {
+    subscribeToTeamUpdates(message => {
+      if (message.data.userId === props.user.id ||
+          message.data.teamId === props.team.id) {
+        refetch()
+      }
+    }, "ViewTeam")
+
+    return () => unsubscribeFromTeamUpdates("ViewTeam")
+  })
+
+  if (error) {
+    throw error
+  }
+
+  if (loading) {
+    return <BusySpinner />
+  }
+
+  const userTeamMember = AsTeamMember(
+    _find(data.teamUsers, teamUser => AsTeamMember(teamUser).isUser(props.user))
+  )
+
+  const activeMembers =
+    _filter(data.teamUsers, teamUser => AsTeamMember(teamUser).isActive())
+
+  const invitedMembers = 
+    _filter(data.teamUsers, teamUser => AsTeamMember(teamUser).isInvited())
+
+  return (
+    <div>
+      <div className="mr-text-lg mr-text-mango">{props.team.name}</div>
+      <div className="mr-mt-2 mr-mb-4 mr-text-sm">{props.team.description}</div>
+
+      <h5 className="mr-mt-6 mr-mb-2 mr-text-xs mr-uppercase mr-text-pink">
+        <FormattedMessage {...messages.activeMembersHeader} />
+      </h5>
+      <ul className="mr-links-green-lighter">
+        {activeMembers.map(member =>
+           <MemberItem
+             key={member.id}
+             {...props}
+             teamUser={member}
+             userTeamMember={userTeamMember}
+           />
+        )}
+      </ul>
+
+      {invitedMembers.length > 0 &&
+       <React.Fragment>
+        <h5 className="mr-mt-6 mr-mb-2 mr-text-xs mr-uppercase mr-text-pink">
+          <FormattedMessage {...messages.invitedMembersHeader} />
+        </h5>
+        <ul className="mr-links-green-lighter">
+          {invitedMembers.map(member =>
+             <MemberItem
+               key={member.id}
+               {...props}
+               teamUser={member}
+               userTeamMember={userTeamMember}
+             />
+          )}
+        </ul>
+       </React.Fragment>
+      }
+
+      {userTeamMember.isTeamAdmin() &&
+       <React.Fragment>
+         <h5 className="mr-mt-6 mr-mb-2 mr-text-base mr-text-yellow">
+           <FormattedMessage {...messages.addMembersHeader} />
+         </h5>
+         <AddTeamMember {...props} />
+       </React.Fragment>
+      }
+    </div>
+  )
+}
+
+ViewTeam.propTypes = {
+  team: PropTypes.object.isRequired,
+}
+
+export default ViewTeam
diff --git a/src/components/Widgets/TeamsWidget/Messages.js b/src/components/Widgets/TeamsWidget/Messages.js
new file mode 100644
index 00000000..b06eb1b8
--- /dev/null
+++ b/src/components/Widgets/TeamsWidget/Messages.js
@@ -0,0 +1,41 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with TeamsWidget
+ */
+export default defineMessages({
+  title: {
+    id: "Widgets.TeamsWidget.label",
+    defaultMessage: "Teams",
+  },
+
+  myTeamsTitle: {
+    id: "Widgets.TeamsWidget.myTeamsTitle",
+    defaultMessage: "My Teams",
+  },
+
+  editTeamTitle: {
+    id: "Widgets.TeamsWidget.editTeamTitle",
+    defaultMessage: "Edit Team",
+  },
+
+  createTeamTitle: {
+    id: "Widgets.TeamsWidget.createTeamTitle",
+    defaultMessage: "Create New Team",
+  },
+
+  viewTeamTitle: {
+    id: "Widgets.TeamsWidget.viewTeamTitle",
+    defaultMessage: "Team Details",
+  },
+
+  myTeamsLabel: {
+    id: "Widgets.TeamsWidget.controls.myTeams.label",
+    defaultMessage: "My Teams",
+  },
+
+  createTeamLabel: {
+    id: "Widgets.TeamsWidget.controls.createTeam.label",
+    defaultMessage: "Start a Team",
+  },
+})
diff --git a/src/components/Widgets/TeamsWidget/TeamsWidget.js b/src/components/Widgets/TeamsWidget/TeamsWidget.js
new file mode 100644
index 00000000..60dfd026
--- /dev/null
+++ b/src/components/Widgets/TeamsWidget/TeamsWidget.js
@@ -0,0 +1,116 @@
+import React, { useState } from 'react'
+import { FormattedMessage } from 'react-intl'
+import _isEmpty from 'lodash/isEmpty'
+import { WidgetDataTarget, registerWidgetType }
+       from '../../../services/Widget/Widget'
+import QuickWidget from '../../QuickWidget/QuickWidget'
+import Dropdown from '../../Dropdown/Dropdown'
+import SvgSymbol from '../../SvgSymbol/SvgSymbol'
+import MyTeams from '../../Teams/MyTeams/MyTeams'
+import ViewTeam from '../../Teams/ViewTeam/ViewTeam'
+import EditTeam from '../../Teams/EditTeam/EditTeam'
+import messages from './Messages'
+
+const descriptor = {
+  widgetKey: 'TeamsWidget',
+  label: messages.title,
+  targets: [WidgetDataTarget.user],
+  minWidth: 3,
+  defaultWidth: 4,
+  defaultHeight: 10,
+}
+
+const TeamsWidget = props => {
+  const [editingTeam, setEditingTeam] = useState(null)
+  const [viewingTeam, setViewingTeam] = useState(null)
+
+  if (!props.user) {
+    return null
+  }
+
+  let widgetTitle = messages.title
+  let headerControls = null
+  let currentView = null
+
+  if (editingTeam) {
+    currentView = (
+      <EditTeam
+        {...props}
+        team={editingTeam}
+        finish={() => setEditingTeam(null)}
+      />
+    )
+
+    widgetTitle =
+      _isEmpty(editingTeam) ?
+      messages.createTeamTitle :
+      messages.editTeamTitle
+  }
+  else if (viewingTeam) {
+    currentView = <ViewTeam {...props} team={viewingTeam} />
+    widgetTitle = messages.viewTeamTitle
+    headerControls = (
+      <div className="mr-links-green-lighter">
+        {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+        <a className="mr-mb-4" onClick={() => setViewingTeam(null)}>
+          &larr; <FormattedMessage {...messages.myTeamsLabel} />
+        </a>
+      </div>
+    )
+  }
+  else {
+    currentView = (
+      <MyTeams
+        {...props}
+        viewTeam={team => setViewingTeam(team)}
+        editTeam={team => setEditingTeam(team)}
+        createTeam={() => setEditingTeam({})}
+      />
+    )
+    widgetTitle = messages.myTeamsTitle
+    headerControls = (
+      <Dropdown
+        className="mr-dropdown--right"
+        dropdownButton={dropdown => (
+          <button
+            onClick={dropdown.toggleDropdownVisible}
+            className="mr-flex mr-items-center mr-text-green-lighter"
+          >
+            <SvgSymbol
+              sym="cog-icon"
+              viewBox="0 0 20 20"
+              className="mr-fill-current mr-w-4 mr-h-4"
+            />
+          </button>
+        )}
+        dropdownContent={(dropdown) => (
+          <ul className="mr-list-dropdown">
+            <li>
+              <button
+                className="mr-text-current"
+                onClick={() => setEditingTeam({})}
+              >
+                <FormattedMessage {...messages.createTeamLabel} />
+              </button>
+            </li>
+          </ul>
+        )}
+      />
+    )
+  }
+
+  return (
+    <QuickWidget
+      {...props}
+      className=""
+      widgetTitle={<FormattedMessage {...widgetTitle} />}
+      rightHeaderControls={headerControls}
+      noMain
+    >
+      {currentView}  
+    </QuickWidget>
+  )
+}
+
+registerWidgetType(TeamsWidget, descriptor)
+export default TeamsWidget
diff --git a/src/components/Widgets/widget_registry.js b/src/components/Widgets/widget_registry.js
index 9c0073ad..4b65d0c5 100644
--- a/src/components/Widgets/widget_registry.js
+++ b/src/components/Widgets/widget_registry.js
@@ -5,6 +5,8 @@
  */
 
 // Import first-party widgets shipped with MapRoulette
+export { default as MyTeamsWidget }
+       from './TeamsWidget/TeamsWidget'
 export { default as FeatureStyleLegendWidget }
        from './FeatureStyleLegendWidget/FeatureStyleLegendWidget'
 export { default as TaskBundleWidget }
diff --git a/src/index.js b/src/index.js
index f25b40de..c6d17473 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,6 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux'
 import { IntlProvider } from 'react-intl'
+import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client'
 import { Router } from 'react-router-dom'
 import { createBrowserHistory } from 'history'
 import PiwikReactRouter from 'piwik-react-router'
@@ -24,6 +25,15 @@ import './index.css'
 import 'leaflet.markercluster/dist/MarkerCluster.css'
 import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
 
+// Setup Apollo graphql client
+const graphqlClient = new ApolloClient({
+  cache: new InMemoryCache(),
+  link: new HttpLink({
+    uri: process.env.REACT_APP_MAP_ROULETTE_SERVER_GRAPHQL_URL,
+    credentials: 'include',
+  })
+})
+
 // Setup the router history object separately so that it can be integrated
 // with 3rd-party libraries. If the user has configured Matomo/PIWIK for
 // analytics, we'll hook it up here.
@@ -83,11 +93,13 @@ store.dispatch(
 // Render the app
 ReactDOM.render(
   <Provider store={store}>
-    <ConnectedIntl>
-      <Router history={routerHistory}>
-        <App />
-      </Router>
-    </ConnectedIntl>
+    <ApolloProvider client={graphqlClient}>
+      <ConnectedIntl>
+        <Router history={routerHistory}>
+          <App />
+        </Router>
+      </ConnectedIntl>
+    </ApolloProvider>
   </Provider>,
   document.getElementById('root')
 )
diff --git a/src/interactions/TeamMember/AsTeamMember.js b/src/interactions/TeamMember/AsTeamMember.js
new file mode 100644
index 00000000..35b5adec
--- /dev/null
+++ b/src/interactions/TeamMember/AsTeamMember.js
@@ -0,0 +1,68 @@
+import { Role, mostPrivilegedRole, rolesImply, messagesByRole }
+       from '../../services/Grant/Role'
+import { TeamStatus, messagesByTeamStatus }
+       from '../../services/Team/Status'
+import _map from 'lodash/map'
+
+/**
+ * Provides methods specific to team membership and management with regard to
+ * the wrapped team user
+ */
+export class AsTeamMember {
+  constructor(teamUser) {
+    Object.assign(this, teamUser)
+  }
+
+  /**
+   * Returns true if the user represented by this team member matches the given
+   * user
+   */
+  isUser(user) {
+    return this.userId === user.id
+  }
+
+  /**
+   * Returns the user's highest-privileged role on the team
+   */
+  highestRole() {
+    const roles = _map(this.teamGrants, 'role')
+    return roles.length > 0 ? mostPrivilegedRole(roles) : null
+  }
+
+  /**
+   * Returns an internationalized Message object describing the member's role
+   * on the team. For active team members, the message will describe the
+   * member's most privileged role on the team; for merely invited members, the
+   * message simply indicates they've been invited to join the team
+   */
+  roleDescription() {
+    if (this.isInvited()) {
+      return messagesByTeamStatus[TeamStatus.invited]
+    }
+
+    const role = this.highestRole()
+    if (!role) {
+      // This shouldn't really happen, but just in case
+      return messagesByTeamStatus[TeamStatus.member]
+    }
+
+    return messagesByRole[role]
+  }
+
+  /**
+   * Returns true if this team member is admin of their team, false if not
+   */
+  isTeamAdmin() {
+    return this.isActive() && rolesImply(Role.admin, _map(this.teamGrants, 'role'))
+  }
+
+  isActive() {
+    return this.status === TeamStatus.member
+  }
+
+  isInvited() {
+    return this.status === TeamStatus.invited
+  }
+}
+
+export default teamUserMember => new AsTeamMember(teamUserMember)
diff --git a/src/interactions/User/AsManager.js b/src/interactions/User/AsManager.js
index bbaba67d..89a115fd 100644
--- a/src/interactions/User/AsManager.js
+++ b/src/interactions/User/AsManager.js
@@ -30,7 +30,7 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Returns the user's roles for the given project
+   * Returns the roles granted to the user user on the given project
    */
   projectRoles(project) {
     if (!this.user || !project) {
@@ -47,10 +47,8 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Determines if the user's roles satisfy (meet or exceed) the given
-   * role for the given project. Returns true if either:
-   * - The user is a superuser
-   * - The user possesses a role for the project that implies the given role
+   * Determines if the user's roles satisfy (meet or exceed) the given role for
+   * the given project
    */
   satisfiesProjectRole(project, role) {
     if (!this.isLoggedIn()) {
@@ -61,11 +59,7 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Determines if the user has permission to read the given project. A user
-   * is considered to have read access if any of the following are true:
-   * - They are a superuser
-   * - They are the project owner
-   * - They possess any of the project's roles (read, write, or admin).
+   * Determines if the user has been granted a read role or higher on a project
    *
    * @returns true if the user has read access, false otherwise.
    */
@@ -74,12 +68,8 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Determines if the user has permission to write to the given project. A user
-   * is considered to have write access if any of the following are true:
-   * - They are a superuser
-   * - They are the project owner
-   * - They possess the project's write role
-   * - They possess the project's admin role
+   * Determines if the user has been granted a write role or higher on a
+   * project
    *
    * @returns true if the user has read access, false otherwise.
    */
@@ -98,12 +88,8 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Determines if the given user can administrate the given project, which
-   * represents higher authority than simply managing it. A user is considered
-   * a project administrator if any of the following are true:
-   * - They are a superuser
-   * - They are the project owner
-   * - They possess the project's admin group
+   * Determines if the given user has been granted an admin role on the project
+   * (or is a superuser)
    *
    * @returns true if the user can administrate the project, false otherwise.
    */
@@ -158,6 +144,51 @@ export class AsManager extends AsEndUser {
     })
     return [...projectChallenges]
   }
+
+  /**
+   * Returns the user's granted roles on a group
+   */
+  groupRoles(group) {
+    if (!this.user || !group) {
+      return []
+    }
+
+    return _map(
+      _filter(this.user.grants, grant =>
+        grant.role === ROLE_SUPERUSER ||
+        (grant.target.objectType === TargetType.group && grant.target.objectId === group.id)
+      ),
+      'role'
+    )
+  }
+
+  /**
+   * Determines if the user's roles satisfy (meet or exceed) the given
+   * role for the given group
+   */
+  satisfiesGroupRole(group, role) {
+    if (!this.isLoggedIn()) {
+      return false
+    }
+
+    return rolesImply(role, this.groupRoles(group))
+  }
+
+  /**
+   * Determines if the given has an admin role on the group (or is a superuser)
+   *
+   * @returns true if the user can administrate the group, false otherwise.
+   */
+  canAdministrateGroup(group) {
+    return this.satisfiesGroupRole(group, Role.admin)
+  }
+
+  /**
+   * Alias for canAdministrateGroup, as teams are groups
+   */
+  canAdministrateTeam(team) {
+    return this.canAdministrateGroup(team)
+  }
 }
 
 export default user => new AsManager(user)
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index 2638b19e..69fbb96f 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -283,6 +283,10 @@
   "Admin.ProjectManagers.controls.removeManager.confirmation": "Are you sure you wish to remove this manager from the project?",
   "Admin.ProjectManagers.controls.selectRole.choose.label": "Choose Role",
   "Admin.ProjectManagers.controls.chooseOSMUser.placeholder": "OpenStreetMap username",
+  "Admin.ProjectManagers.controls.chooseTeam.placeholder": "Team name",
+  "Admin.ProjectManagers.options.teams.label": "Team",
+  "Admin.ProjectManagers.options.users.label": "User",
+  "Admin.ProjectManagers.team.indicator": "Team",
   "Widgets.ProjectOverviewWidget.label": "Overview",
   "Widgets.ProjectOverviewWidget.title": "Overview",
   "Widgets.RecentActivityWidget.label": "Recent Activity",
@@ -623,6 +627,7 @@
   "ProjectDetails.fields.modified.label": "Modified",
   "ProjectDetails.fields.viewLeaderboard.label": "View Leaderboard",
   "ProjectDetails.fields.viewReviews.label": "Review",
+  "QuickWidget.failedToLoad": "Widget Failed",
   "Admin.TaskReview.controls.updateReviewStatusTask.label": "Update Review Status",
   "Admin.TaskReview.controls.currentTaskStatus.label": "Task Status:",
   "Admin.TaskReview.controls.currentReviewStatus.label": "Review Status:",
@@ -640,6 +645,7 @@
   "Admin.TaskReview.controls.resubmit": "Submit for Review Again",
   "ReviewTaskPane.indicators.locked.label": "Task locked",
   "ReviewTaskPane.controls.unlock.label": "Unlock",
+  "RolePicker.chooseRole.label": "Choose Role",
   "UserProfile.favoriteChallenges.header": "Favorite Challenges",
   "Challenge.controls.unsave.tooltip": "Unfavorite Challenge",
   "SavedChallenges.widget.noChallenges": "No Challenges",
@@ -849,6 +855,29 @@
   "Taxonomy.indicators.favorite.label": "Favorite",
   "Taxonomy.indicators.tagFix.label": "Tag Fix",
   "Taxonomy.indicators.cooperative.label": "Cooperative",
+  "AddTeamMember.controls.chooseRole.label": "Choose Role",
+  "AddTeamMember.controls.chooseOSMUser.placeholder": "OpenStreetMap username",
+  "Team.name.label": "Name",
+  "Team.name.description": "The unique name of the team",
+  "Team.description.label": "Description",
+  "Team.description.description": "A brief description of the team",
+  "Team.controls.save.label": "Save",
+  "Team.controls.cancel.label": "Cancel",
+  "Team.member.controls.acceptInvite.label": "Join Team",
+  "Team.member.controls.declineInvite.label": "Decline Invite",
+  "Team.member.controls.delete.label": "Remove User",
+  "Team.member.controls.leave.label": "Leave Team",
+  "Team.members.indicator.you.label": "(you)",
+  "Team.noTeams": "You are not a member of any teams",
+  "Team.controls.view.label": "View Team",
+  "Team.controls.edit.label": "Edit Team",
+  "Team.controls.delete.label": "Delete Team",
+  "Team.controls.acceptInvite.label": "Join Team",
+  "Team.controls.declineInvite.label": "Decline Invite",
+  "Team.controls.leave.label": "Leave Team",
+  "Team.activeMembers.header": "Active Members",
+  "Team.invitedMembers.header": "Pending Invitations",
+  "Team.addMembers.header": "Invite New Member",
   "UserProfile.topChallenges.header": "Your Top Challenges",
   "TopUserChallenges.widget.label": "Your Top Challenges",
   "TopUserChallenges.widget.noChallenges": "No Challenges",
@@ -924,6 +953,13 @@
   "Widgets.review.simultaneousTasks": "Reviewing {taskCount, number} tasks together",
   "Widgets.TaskStatusWidget.label": "Task Status",
   "Widgets.TaskStatusWidget.title": "Task Status",
+  "Widgets.TeamsWidget.label": "Teams",
+  "Widgets.TeamsWidget.myTeamsTitle": "My Teams",
+  "Widgets.TeamsWidget.editTeamTitle": "Edit Team",
+  "Widgets.TeamsWidget.createTeamTitle": "Create New Team",
+  "Widgets.TeamsWidget.viewTeamTitle": "Team Details",
+  "Widgets.TeamsWidget.controls.myTeams.label": "My Teams",
+  "Widgets.TeamsWidget.controls.createTeam.label": "Start a Team",
   "WidgetWorkspace.controls.editConfiguration.label": "Edit Layout",
   "WidgetWorkspace.controls.saveConfiguration.label": "Done Editing",
   "WidgetWorkspace.fields.configurationName.label": "Layout Name:",
@@ -1108,6 +1144,7 @@
   "Activity.item.tag": "Tag",
   "Activity.item.survey": "Survey",
   "Activity.item.user": "User",
+  "Activity.item.group": "Group",
   "Activity.item.virtualChallenge": "Virtual Challenge",
   "Activity.item.bundle": "Bundle",
   "Activity.item.grant": "Grant",
@@ -1200,6 +1237,7 @@
   "Errors.widgetWorkspace.importFailure": "Unable to import layout{details}",
   "Errors.josm.noResponse": "OSM remote control did not respond. Do you have JOSM running with Remote Control enabled?",
   "Errors.josm.missingFeatureIds": "This task's features do not include the OSM identifiers required to open them standalone in JOSM. Please choose another editing option.",
+  "Errors.team.genericFailure": "Failure{details}",
   "Grant.Role.admin": "Admin",
   "Grant.Role.write": "Write",
   "Grant.Role.read": "Read",
@@ -1270,6 +1308,8 @@
   "Task.status.disabled": "Disabled",
   "Task.status.alreadyFixed": "Already Fixed",
   "Task.status.tooHard": "Too Hard",
+  "Team.Status.member": "Member",
+  "Team.Status.invited": "Invited",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
   "Locale.de.label": "de (Deutsch)",
diff --git a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
index 70cf535f..e7b10a12 100644
--- a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
+++ b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
@@ -10,6 +10,7 @@ export const ITEM_TYPE_TASK = 2
 export const ITEM_TYPE_TAG = 3
 export const ITEM_TYPE_SURVEY = 4
 export const ITEM_TYPE_USER = 5
+export const ITEM_TYPE_GROUP = 6
 export const ITEM_TYPE_VIRTUAL_CHALLENGE = 7
 export const ITEM_TYPE_BUNDLE = 8
 export const ITEM_TYPE_GRANT = 9
@@ -21,6 +22,7 @@ export const ActivityItemType = Object.freeze({
   tag: ITEM_TYPE_TAG,
   survey: ITEM_TYPE_SURVEY,
   user: ITEM_TYPE_USER,
+  group: ITEM_TYPE_GROUP,
   virtualChallenge: ITEM_TYPE_VIRTUAL_CHALLENGE,
   bundle: ITEM_TYPE_BUNDLE,
   grant: ITEM_TYPE_GRANT,
diff --git a/src/services/Activity/ActivityItemTypes/Messages.js b/src/services/Activity/ActivityItemTypes/Messages.js
index ab154afc..7ac8642d 100644
--- a/src/services/Activity/ActivityItemTypes/Messages.js
+++ b/src/services/Activity/ActivityItemTypes/Messages.js
@@ -28,6 +28,10 @@ export default defineMessages({
     id: "Activity.item.user",
     defaultMessage: "User"
   },
+  group: {
+    id: "Activity.item.group",
+    defaultMessage: "Group"
+  },
   virtualChallenge: {
     id: "Activity.item.virtualChallenge",
     defaultMessage: "Virtual Challenge"
diff --git a/src/services/Error/AppErrors.js b/src/services/Error/AppErrors.js
index 9575ef6f..3c10b2cb 100644
--- a/src/services/Error/AppErrors.js
+++ b/src/services/Error/AppErrors.js
@@ -101,4 +101,8 @@ export default {
     noResponse: messages.josmNoResponse,
     missingOSMIds: messages.josmMissingOSMIds,
   },
+
+  team: {
+    failure: messages.teamFailure,
+  },
 }
diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js
index 5bba1c49..058e3fd0 100644
--- a/src/services/Error/Messages.js
+++ b/src/services/Error/Messages.js
@@ -222,4 +222,8 @@ export default defineMessages({
                     "required to open them standalone in JOSM. Please choose " +
                     "another editing option."
   },
+  teamFailure: {
+    id: "Errors.team.genericFailure",
+    defaultMessage: "Failure{details}"
+  },
 })
diff --git a/src/services/Grant/TargetType.js b/src/services/Grant/TargetType.js
index cf4f0278..e94e9c91 100644
--- a/src/services/Grant/TargetType.js
+++ b/src/services/Grant/TargetType.js
@@ -3,7 +3,9 @@ import { ActivityItemType }
 
 // Target types use the item type constants on the server
 export const TARGET_TYPE_PROJECT = ActivityItemType.project
+export const TARGET_TYPE_GROUP = ActivityItemType.group
 
 export const TargetType = Object.freeze({
   project: TARGET_TYPE_PROJECT,
+  group: TARGET_TYPE_GROUP,
 })
diff --git a/src/services/Project/Project.js b/src/services/Project/Project.js
index 0bdc69e3..9f2d50c2 100644
--- a/src/services/Project/Project.js
+++ b/src/services/Project/Project.js
@@ -3,6 +3,7 @@ import _get from 'lodash/get'
 import _isArray from 'lodash/isArray'
 import _cloneDeep from 'lodash/cloneDeep'
 import _find from 'lodash/find'
+import _map from 'lodash/map'
 import _isFinite from 'lodash/isFinite'
 import _isUndefined from 'lodash/isUndefined'
 import startOfDay from 'date-fns/start_of_day'
@@ -275,23 +276,36 @@ export const fetchProjectActivity = function(projectId, startDate, endDate) {
 }
 
 /**
- * Fetch managers of the given project.
+ * Fetch managers of the given project, both users and teams
  */
 export const fetchProjectManagers = function(projectId) {
   return function(dispatch) {
-    return new Endpoint(
-      api.project.managers, {variables: {projectId}}
-    ).execute().then(rawManagers => {
-      const normalizedResults = {
-        entities: {
-          projects: {
-            [projectId]: {id: projectId, managers: rawManagers},
-          }
+    const normalizedResults = {
+      entities: {
+        projects: {
+          [projectId]: {id: projectId},
         }
       }
+    }
 
-      return dispatch(receiveProjects(normalizedResults.entities))
-    }).catch(error => {
+    return Promise.all([
+      new Endpoint(
+        api.project.managers, {variables: {projectId}}
+      ).execute().then(rawManagers =>
+        normalizedResults.entities.projects[projectId].managers = rawManagers
+      ),
+
+      new Endpoint(
+        api.teams.projectManagers, {variables: {projectId}}
+      ).execute().then(rawManagers =>
+        normalizedResults.entities.projects[projectId].teamManagers = _map(
+          rawManagers,
+          managingTeam => Object.assign({}, managingTeam.team, {roles: _map(managingTeam.grants, 'role')})
+        )
+      ),
+    ]).then(
+      () => dispatch(receiveProjects(normalizedResults.entities))
+    ).catch(error => {
       if (isSecurityError(error)) {
         dispatch(ensureUserLoggedIn()).then(() =>
           dispatch(addError(AppErrors.user.unauthorized))
@@ -437,6 +451,10 @@ const reduceProjectsFurther = function(mergedState, oldState, projectEntities) {
     if (_isArray(entity.managers)) {
       mergedState[entity.id].managers = entity.managers
     }
+
+    if (_isArray(entity.teamManagers)) {
+      mergedState[entity.id].teamManagers = entity.teamManagers
+    }
   })
 }
 
diff --git a/src/services/Server/APIRoutes.js b/src/services/Server/APIRoutes.js
index 29c5931d..96e2089b 100644
--- a/src/services/Server/APIRoutes.js
+++ b/src/services/Server/APIRoutes.js
@@ -152,6 +152,14 @@ const apiRoutes = factory => {
       'markNotificationsRead': factory.put('/user/:userId/notifications'),
       'deleteNotifications': factory.delete('/user/:userId/notifications'),
     },
+    'teams': {
+      'find': factory.get('/teams/find'),
+      'projectManagers': factory.get('/teams/projectManagers/:projectId'),
+    },
+    'team': {
+      'setProjectRole': factory.put('/team/:teamId/project/:projectId/:role'),
+      'removeFromProject': factory.delete('/team/:teamId/project/:projectId'),
+    }
   }
 }
 
diff --git a/src/services/Team/Messages.js b/src/services/Team/Messages.js
new file mode 100644
index 00000000..ac499446
--- /dev/null
+++ b/src/services/Team/Messages.js
@@ -0,0 +1,15 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with Team Status
+ */
+export default defineMessages({
+  member: {
+    id: "Team.Status.member",
+    defaultMessage: "Member",
+  },
+  invited: {
+    id: "Team.Status.invited",
+    defaultMessage: "Invited",
+  },
+})
diff --git a/src/services/Team/Status.js b/src/services/Team/Status.js
new file mode 100644
index 00000000..a0ba73b8
--- /dev/null
+++ b/src/services/Team/Status.js
@@ -0,0 +1,25 @@
+import _map from 'lodash/map'
+import _fromPairs from 'lodash/fromPairs'
+import messages from './Messages'
+
+// These constants are defined on the server
+export const STATUS_MEMBER = 0
+export const STATUS_INVITED = 1
+
+export const TeamStatus = Object.freeze({
+  member: STATUS_MEMBER,
+  invited: STATUS_INVITED,
+})
+
+/**
+ * Returns an object mapping team status values to raw internationalized
+ * messages suitable for use with FormattedMessage or formatMessage
+ */
+export const messagesByTeamStatus = _fromPairs(
+  _map(messages, (message, key) => [TeamStatus[key], message])
+)
+
+/** Returns object containing localized labels  */
+export const teamStatusLabels = intl => _fromPairs(
+  _map(messages, (message, key) => [key, intl.formatMessage(message)])
+)
diff --git a/src/services/Team/Team.js b/src/services/Team/Team.js
new file mode 100644
index 00000000..d5a3f1cf
--- /dev/null
+++ b/src/services/Team/Team.js
@@ -0,0 +1,51 @@
+import { defaultRoutes as api, websocketClient } from '../Server/Server'
+import Endpoint from '../Server/Endpoint'
+import { fetchProjectManagers } from '../Project/Project'
+
+export const subscribeToTeamUpdates = function(callback, handle) {
+  websocketClient.addServerSubscription(
+    "teams",
+    null,
+    handle,
+    messageObject => callback(messageObject)
+  )
+}
+
+export const unsubscribeFromTeamUpdates = function(handle) {
+  websocketClient.removeServerSubscription("teams", null, handle)
+}
+
+// async action creators
+
+/**
+ * Search for teams by name. Resolves with a (possibly empty) list of results
+ */
+export const findTeam = function(teamName) {
+  return new Endpoint(api.teams.find, {params: {name: teamName}}).execute()
+}
+
+/**
+ * Set a team's granted role on a project
+ */
+export const setTeamProjectRole = function(projectId, teamId, role) {
+  return function(dispatch) {
+    return new Endpoint(api.team.setProjectRole, {
+      variables: {teamId, projectId, role},
+    }).execute().then(() =>
+      fetchProjectManagers(projectId)(dispatch)
+    )
+  }
+}
+
+/**
+ * Set a team's granted role on a project
+ */
+export const removeTeamFromProject = function(projectId, teamId) {
+  return function(dispatch) {
+    return new Endpoint(api.team.removeFromProject, {
+      variables: {teamId, projectId},
+    }).execute().then(() =>
+      fetchProjectManagers(projectId)(dispatch)
+    )
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index 26a41ab1..11570d57 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,6 +2,21 @@
 # yarn lockfile v1
 
 
+"@apollo/client@^3.0.0-beta.44":
+  version "3.0.0-beta.44"
+  resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.0.0-beta.44.tgz#79c6083dc2fe81725ad64398bc78e641ed1c1d84"
+  integrity sha512-udNiabIYs9WLHWvj2j6tlsaWPNhgMZsHrZyBTKOh+69fLpmZZ4Vv4mepWq/3WCzyHoWa3n3FPi/ajNljkO/Olg==
+  dependencies:
+    "@types/zen-observable" "^0.8.0"
+    "@wry/equality" "^0.1.9"
+    fast-json-stable-stringify "^2.0.0"
+    graphql-tag "^2.10.2"
+    optimism "^0.11.5"
+    symbol-observable "^1.2.0"
+    ts-invariant "^0.4.4"
+    tslib "^1.10.0"
+    zen-observable "^0.8.14"
+
 "@babel/code-frame@7.8.3", "@babel/code-frame@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
@@ -2417,6 +2432,11 @@
   dependencies:
     "@types/yargs-parser" "*"
 
+"@types/zen-observable@^0.8.0":
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d"
+  integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==
+
 "@typescript-eslint/eslint-plugin@^2.10.0":
   version "2.18.0"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.18.0.tgz#f8cf272dfb057ecf1ea000fea1e0b3f06a32f9cb"
@@ -2588,6 +2608,20 @@
     "@webassemblyjs/wast-parser" "1.8.5"
     "@xtuc/long" "4.2.2"
 
+"@wry/context@^0.5.0":
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.5.2.tgz#f2a5d5ab9227343aa74c81e06533c1ef84598ec7"
+  integrity sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw==
+  dependencies:
+    tslib "^1.9.3"
+
+"@wry/equality@^0.1.9":
+  version "0.1.11"
+  resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790"
+  integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA==
+  dependencies:
+    tslib "^1.9.3"
+
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@@ -7051,6 +7085,16 @@ graceful-fs@^4.2.0:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02"
 
+graphql-tag@^2.10.2:
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.3.tgz#ea1baba5eb8fc6339e4c4cf049dabe522b0edf03"
+  integrity sha512-4FOv3ZKfA4WdOKJeHdz6B3F/vxBLSgmBcGeAFPf4n1F64ltJUvOOerNj0rsJxONQGdhUMynQIvd6LzB+1J5oKA==
+
+graphql@^15.0.0:
+  version "15.0.0"
+  resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.0.0.tgz#042a5eb5e2506a2e2111ce41eb446a8e570b8be9"
+  integrity sha512-ZyVO1xIF9F+4cxfkdhOJINM+51B06Friuv4M66W7HzUOeFd+vNzUn4vtswYINPi6sysjf1M2Ri/rwZALqgwbaQ==
+
 growly@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@@ -10019,6 +10063,13 @@ opn@^5.5.0:
   dependencies:
     is-wsl "^1.1.0"
 
+optimism@^0.11.5:
+  version "0.11.5"
+  resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.11.5.tgz#4c5d45fa0fa1cc9dcf092729b5d6d661b53ff5c9"
+  integrity sha512-twCHmBb64DYzEZ8A3O+TLCuF/RmZPBhXPQYv4agoiALRLlW9SidMzd7lwUP9mL0jOZhzhnBmb8ajqA00ECo/7g==
+  dependencies:
+    "@wry/context" "^0.5.0"
+
 optimist@^0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
@@ -13763,6 +13814,13 @@ trough@^1.0.0:
   dependencies:
     glob "^7.1.2"
 
+ts-invariant@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
+  integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==
+  dependencies:
+    tslib "^1.9.3"
+
 ts-pnp@1.1.5:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.5.tgz#840e0739c89fce5f3abd9037bb091dbff16d9dec"
@@ -13772,6 +13830,11 @@ ts-pnp@^1.1.2:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.4.tgz#ae27126960ebaefb874c6d7fa4729729ab200d90"
 
+tslib@^1.10.0, tslib@^1.9.3:
+  version "1.11.2"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.2.tgz#9c79d83272c9a7aaf166f73915c9667ecdde3cc9"
+  integrity sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg==
+
 tslib@^1.8.1, tslib@^1.9.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
@@ -14803,3 +14866,8 @@ yargs@^7.0.0:
     which-module "^1.0.0"
     y18n "^3.2.1"
     yargs-parser "^5.0.0"
+
+zen-observable@^0.8.14:
+  version "0.8.15"
+  resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
+  integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==


Test patch:
diff --git a/src/interactions/User/AsManager.test.js b/src/interactions/User/AsManager.test.js
index 25a72cc8..6efe6e12 100644
--- a/src/interactions/User/AsManager.test.js
+++ b/src/interactions/User/AsManager.test.js
@@ -12,9 +12,11 @@ const writeGrant234 = {id: 234, role: Role.write, target: {objectType: TargetTyp
 const adminGrant102 = {id: 102, role: Role.admin, target: {objectType: TargetType.project, objectId: 102}}
 const writeGrant102 = {id: 1021, role: Role.write, target: {objectType: TargetType.project, objectId: 102}}
 const readGrant102 = {id: 1022, role: Role.read, target: {objectType: TargetType.project, objectId: 102}}
+const groupAdminGrant987 = {id: 9871, role: Role.admin, target: {objectType: TargetType.group, objectId: 987}}
+const groupWriteGrant987 = {id: 9872, role: Role.write, target: {objectType: TargetType.group, objectId: 987}}
 
-const powerUser = {id: 246, grants: [adminGrant123, readGrant456, adminGrant102, readGrant102]}
-const writeUser = {id: 910, grants: [writeGrant234, writeGrant102]}
+const powerUser = {id: 246, grants: [adminGrant123, readGrant456, adminGrant102, readGrant102, groupAdminGrant987]}
+const writeUser = {id: 910, grants: [writeGrant234, writeGrant102, groupWriteGrant987]}
 const superUser = {id: 135, grants: [superGrant]}
 const normalUser = {id: 790, osmProfile: {id: 987654321}, grants: []}
 
@@ -29,6 +31,8 @@ const challenge123_2 = {id: 1232, parent: 123}
 const challenge456_1 = {id: 4561, parent: 456}
 const challenge789_1 = {id: 7891, parent: 789}
 
+const group987 = {id: 987}
+
 describe('projectRoles', () => {
   it("returns the project roles possessed by the user", () => {
     const manager = AsManager(powerUser)
@@ -220,3 +224,35 @@ describe('manageableChallenges', () => {
     expect(manageable).toContain(challenge456_1)
   })
 })
+
+describe('canAdministrateGroup', () => {
+  it("always returns true if the user is a superuser", () => {
+    const manager = AsManager(superUser)
+
+    expect(manager.canAdministrateGroup(group987)).toBe(true)
+  })
+
+  it("returns false if the user has no grants on the group", () => {
+    const manager = AsManager(normalUser)
+
+    expect(manager.canAdministrateGroup(group987)).toBe(false)
+  })
+
+  it("returns true if the user is granted admin role on the group", () => {
+    const manager = AsManager(powerUser)
+
+    expect(manager.canAdministrateGroup(group987)).toBe(true)
+  })
+
+  it("returns false if the user granted non-admin role on the group", () => {
+    const manager = AsManager(writeUser)
+
+    expect(manager.canAdministrateGroup(group987)).toBe(false)
+  })
+
+  it("returns false if the user is undefined", () => {
+    const missingUser = AsManager(undefined)
+
+    expect(missingUser.canAdministrateGroup(group987)).toBe(false)
+  })
+})