Repository: osmlab/maproulette3
Pull request #1220 (2020-04-29 01:59:42)

Support conversion of groups to grants
> Note: this depends on backend PR maproulette/maproulette2#773


Patch:
diff --git a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
index aae3dbf8..d26598cb 100644
--- a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
+++ b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js
@@ -11,7 +11,7 @@ import { fetchManageableProjects,
          fetchProject,
          fetchProjectsById,
          addProjectManager,
-         setProjectManagerGroupType,
+         setProjectManagerRole,
          fetchProjectManagers,
          removeProjectManager,
          saveProject,
@@ -124,7 +124,7 @@ const mapDispatchToProps = dispatch => {
     saveProject,
     addProjectManager,
     fetchProjectManagers,
-    setProjectManagerGroupType,
+    setProjectManagerRole,
     removeProjectManager,
     addChallenge,
     removeChallenge,
diff --git a/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js b/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js
index a01c16b3..94ab45de 100644
--- a/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js
+++ b/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js
@@ -74,19 +74,14 @@ export class ProjectsDashboard extends Component {
 
     return (
       <div className="admin__manage projects-dashboard">
-        {!this.props.loadingProjects && this.props.projects.length === 0 ?
-         <div className="projects-dashboard__no-projects">
-           <FormattedMessage {...messages.regenerateHomeProject} />
-         </div> :
-         <WidgetWorkspace
-           {...this.props}
-           lightMode={false}
-           darkMode
-           className="mr-mt-4 mr-cards-inverse"
-           workspaceEyebrow={pageHeader}
-           filterComponent={ProjectFilterGroup}
-         />
-        }
+        <WidgetWorkspace
+          {...this.props}
+          lightMode={false}
+          darkMode
+          className="mr-mt-4 mr-cards-inverse"
+          workspaceEyebrow={pageHeader}
+          filterComponent={ProjectFilterGroup}
+        />
       </div>
     )
   }
diff --git a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
index 30a22c16..6d446dc8 100644
--- a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
+++ b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js
@@ -7,10 +7,10 @@ import _map from 'lodash/map'
 import _filter from 'lodash/filter'
 import _without from 'lodash/without'
 import _isFinite from 'lodash/isFinite'
-import { GroupType,
-         mostPrivilegedGroupType,
-         messagesByGroupType }
-       from '../../../../../services/Project/GroupType/GroupType'
+import { Role,
+         mostPrivilegedRole,
+         messagesByRole }
+       from '../../../../../services/Grant/Role'
 import WithOSMUserSearch from '../../../HOCs/WithOSMUserSearch/WithOSMUserSearch'
 import AsManager from '../../../../../interactions/User/AsManager'
 import AsAvatarUser from '../../../../../interactions/User/AsAvatarUser'
@@ -45,8 +45,8 @@ export default class ProjectManagersWidget extends Component {
     )
   }
 
-  updateManagerRole = (managerOsmId, groupType) => {
-    if (groupType === 'remove') {
+  updateManagerRole = (managerOsmId, role) => {
+    if (role === 'remove') {
       this.removeManager(managerOsmId)
       return
     }
@@ -55,8 +55,8 @@ export default class ProjectManagersWidget extends Component {
       updatingManagers: this.state.updatingManagers.concat([managerOsmId])
     })
 
-    this.props.setProjectManagerGroupType(
-      this.props.project.id, managerOsmId, true, groupType
+    this.props.setProjectManagerRole(
+      this.props.project.id, managerOsmId, true, role
     ).then(() => this.setState({
       updatingManagers: _without(this.state.updatingManagers, managerOsmId)
     }))
@@ -74,15 +74,15 @@ export default class ProjectManagersWidget extends Component {
     }))
   }
 
-  addManager = groupType => {
-    if (!_isFinite(parseInt(groupType, 10))) {
+  addManager = role => {
+    if (!_isFinite(parseInt(role, 10))) {
       return
     }
 
     this.setState({addingManager: true})
 
     this.props.addProjectManager(
-      this.props.project.id, this.state.addManagerUsername, groupType
+      this.props.project.id, this.state.addManagerUsername, role
     ).then(() => this.setState({
       addManagerUsername: '',
       addManagerOSMUser: null,
@@ -97,38 +97,37 @@ export default class ProjectManagersWidget extends Component {
 
     const user = AsManager(this.props.user)
 
-    const groupTypeOptions = [
-      <option key={GroupType.read} value={GroupType.read}>
-        {this.props.intl.formatMessage(messagesByGroupType[GroupType.read])}
+    const roleOptions = [
+      <option key={Role.read} value={Role.read}>
+        {this.props.intl.formatMessage(messagesByRole[Role.read])}
       </option>,
 
-      <option key={GroupType.write} value={GroupType.write}>
-        {this.props.intl.formatMessage(messagesByGroupType[GroupType.write])}
+      <option key={Role.write} value={Role.write}>
+        {this.props.intl.formatMessage(messagesByRole[Role.write])}
       </option>,
 
-      <option key={GroupType.admin} value={GroupType.admin}>
-        {this.props.intl.formatMessage(messagesByGroupType[GroupType.admin])}
+      <option key={Role.admin} value={Role.admin}>
+        {this.props.intl.formatMessage(messagesByRole[Role.admin])}
       </option>,
     ]
 
     const adminManagers =
       _filter(this.props.project.managers,
-              manager => mostPrivilegedGroupType(manager.groupTypes) === GroupType.admin)
+              manager => mostPrivilegedRole(manager.roles) === Role.admin)
 
     let managers = _map(this.props.project.managers, manager => {
-      const managerRole = mostPrivilegedGroupType(manager.groupTypes)
-      const isProjectOwner = manager.osmId === this.props.project.owner
-      const isLastAdmin = managerRole === GroupType.admin && adminManagers.length < 2
+      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 && !isProjectOwner) ?
-        groupTypeOptions.concat([
+        user.canAdministrateProject(this.props.project) && !isLastAdmin ?
+        roleOptions.concat([
           <option key="remove" value="remove">
             {this.props.intl.formatMessage(messages.removeManagerLabel)}
           </option>
         ]) :
-        groupTypeOptions
+        roleOptions
 
       return (
         <div key={manager.osmId} className="mr-flex mr-items-center mr-pr-4 mr-mt-4">
@@ -153,16 +152,10 @@ export default class ProjectManagersWidget extends Component {
           <div className="mr-flex-grow-0">
             {this.state.updatingManagers.indexOf(manager.osmId) !== -1 && <BusySpinner />}
 
-            {isLastAdmin || isProjectOwner ?
-             <div>
-               {isProjectOwner ?
-                <FormattedMessage {...messages.projectOwner} /> :
-                <FormattedMessage {...messagesByGroupType[managerRole]} />
-               }
-             </div> :
+            {isLastAdmin || !user.canAdministrateProject(this.props.project) ?
+             <FormattedMessage {...messagesByRole[managerRole]} /> :
              <select
                value={managerRole}
-               disabled={!user.canAdministrateProject(this.props.project)}
                onChange={e => this.updateManagerRole(manager.osmId, e.target.value)}
                className="mr-select mr-py-1"
              >
@@ -214,7 +207,7 @@ export default class ProjectManagersWidget extends Component {
                <option key='none' value=''>
                  {this.props.intl.formatMessage(messages.chooseRole)}
                </option>
-             ].concat(groupTypeOptions)}
+             ].concat(roleOptions)}
            </select>
           }
         </div>
diff --git a/src/interactions/User/AsEndUser.js b/src/interactions/User/AsEndUser.js
index 6bc17cc0..5e363fef 100644
--- a/src/interactions/User/AsEndUser.js
+++ b/src/interactions/User/AsEndUser.js
@@ -1,11 +1,10 @@
 import { GUEST_USER_ID } from '../../services/User/User'
-import { GROUP_TYPE_SUPERUSER }
-       from '../../services/Project/GroupType/GroupType'
+import { ROLE_SUPERUSER } from '../../services/Grant/Role'
 import _find from 'lodash/find'
 import _isObject from 'lodash/isObject'
 import _isNumber from 'lodash/isNumber'
 
-// A hand-culled subset of contrasting colors from:
+// A curated subset of contrasting colors from:
 // http://godsnotwheregodsnot.blogspot.com/2013/11/kmeans-color-quantization-seeding.html
 const CONTRASTING_COLORS = [
   "#FFFF00", "#1CE6FF", "#FF34FF", "#FFDBE5", "#63FFAC", "#B79762", "#8FB0FF",
@@ -48,7 +47,7 @@ export class AsEndUser {
    */
   isSuperUser() {
     return this.isLoggedIn() &&
-           !!_find(this.user.groups, {groupType: GROUP_TYPE_SUPERUSER})
+           !!_find(this.user.grants, {role: ROLE_SUPERUSER})
   }
 
   /**
diff --git a/src/interactions/User/AsManager.js b/src/interactions/User/AsManager.js
index 3b7c907f..bbaba67d 100644
--- a/src/interactions/User/AsManager.js
+++ b/src/interactions/User/AsManager.js
@@ -1,8 +1,6 @@
-import { GroupType,
-         groupTypesImply,
-         GROUP_TYPE_SUPERUSER }
-       from '../../services/Project/GroupType/GroupType'
-import _some from 'lodash/some'
+import { Role, rolesImply, ROLE_SUPERUSER }
+       from '../../services/Grant/Role'
+import { TargetType } from '../../services/Grant/TargetType'
 import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _isObject from 'lodash/isObject'
@@ -32,40 +30,34 @@ export class AsManager extends AsEndUser {
   }
 
   /**
-   * Returns the user's group types (roles) for the given project.
+   * Returns the user's roles for the given project
    */
-  projectGroupTypes(project) {
+  projectRoles(project) {
     if (!this.user || !project) {
       return []
     }
 
     return _map(
-      _filter(this.user.groups, userGroup =>
-        userGroup.groupType === GROUP_TYPE_SUPERUSER ||
-        _some(project.groups, {id: userGroup.id})
+      _filter(this.user.grants, grant =>
+        grant.role === ROLE_SUPERUSER ||
+        (grant.target.objectType === TargetType.project && grant.target.objectId === project.id)
       ),
-      'groupType'
+      'role'
     )
   }
 
   /**
-   * Determines if the user's group types satisfy (meet or exceed) the given
-   * group type for the given project. Returns true if any of the following are
-   * true:
+   * 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 is the project owner
-   * - The user possesses a group type for the project that implies the given group type.
+   * - The user possesses a role for the project that implies the given role
    */
-  satisfiesProjectGroupType(project, groupType) {
+  satisfiesProjectRole(project, role) {
     if (!this.isLoggedIn()) {
       return false
     }
 
-    if (this.isProjectOwner(project)) {
-      return true
-    }
-
-    return groupTypesImply(groupType, this.projectGroupTypes(project))
+    return rolesImply(role, this.projectRoles(project))
   }
 
   /**
@@ -73,12 +65,12 @@ export class AsManager extends AsEndUser {
    * 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 group types (read, write, or admin).
+   * - They possess any of the project's roles (read, write, or admin).
    *
    * @returns true if the user has read access, false otherwise.
    */
   canReadProject(project) {
-    return this.satisfiesProjectGroupType(project, GroupType.read)
+    return this.satisfiesProjectRole(project, Role.read)
   }
 
   /**
@@ -86,13 +78,13 @@ export class AsManager extends AsEndUser {
    * 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 group type
-   * - They possess the project's admin group type
+   * - They possess the project's write role
+   * - They possess the project's admin role
    *
    * @returns true if the user has read access, false otherwise.
    */
   canWriteProject(project) {
-    return this.satisfiesProjectGroupType(project, GroupType.write)
+    return this.satisfiesProjectRole(project, Role.write)
   }
 
   /**
@@ -102,7 +94,7 @@ export class AsManager extends AsEndUser {
    * @returns true if the user can manage the project, false otherwise.
    */
   canManage(project) {
-    return this.satisfiesProjectGroupType(project, GroupType.read)
+    return this.satisfiesProjectRole(project, Role.read)
   }
 
   /**
@@ -116,7 +108,7 @@ export class AsManager extends AsEndUser {
    * @returns true if the user can administrate the project, false otherwise.
    */
   canAdministrateProject(project) {
-    return this.satisfiesProjectGroupType(project, GroupType.admin)
+    return this.satisfiesProjectRole(project, Role.admin)
   }
 
   /**
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index 42aeae9e..0f172731 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -1093,7 +1093,9 @@
   "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",
   "Challenge.basemap.none": "None",
   "Admin.Challenge.basemap.none": "User Default",
   "Challenge.basemap.openStreetMap": "OpenStreetMap",
@@ -1183,6 +1185,9 @@
   "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.",
+  "Grant.Role.admin": "Admin",
+  "Grant.Role.write": "Write",
+  "Grant.Role.read": "Read",
   "KeyMapping.openEditor.editId": "Edit in Id",
   "KeyMapping.openEditor.editJosm": "Edit in JOSM",
   "KeyMapping.openEditor.editJosmLayer": "Edit in new JOSM layer",
@@ -1214,9 +1219,6 @@
   "Notification.type.review.again": "Review",
   "Notification.type.challengeCompleted": "Completed",
   "Notification.type.challengeCompletedLong": "Challenge Completed",
-  "Project.GroupType.admin": "Admin",
-  "Project.GroupType.write": "Write",
-  "Project.GroupType.read": "Read",
   "Challenge.sort.name": "Name",
   "Challenge.sort.created": "Newest",
   "Challenge.sort.oldest": "Oldest",
diff --git a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
index 72d30f9e..70cf535f 100644
--- a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
+++ b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js
@@ -10,7 +10,9 @@ 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
 
 export const ActivityItemType = Object.freeze({
   project: ITEM_TYPE_PROJECT,
@@ -19,7 +21,9 @@ 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,
 })
 
 export const keysByType = Object.freeze(_invert(ActivityItemType))
diff --git a/src/services/Activity/ActivityItemTypes/Messages.js b/src/services/Activity/ActivityItemTypes/Messages.js
index b301029f..ab154afc 100644
--- a/src/services/Activity/ActivityItemTypes/Messages.js
+++ b/src/services/Activity/ActivityItemTypes/Messages.js
@@ -28,8 +28,16 @@ export default defineMessages({
     id: "Activity.item.user",
     defaultMessage: "User"
   },
-  group: {
-    id: "Activity.item.group",
-    defaultMessage: "Group"
+  virtualChallenge: {
+    id: "Activity.item.virtualChallenge",
+    defaultMessage: "Virtual Challenge"
+  },
+  bundle: {
+    id: "Activity.item.bundle",
+    defaultMessage: "Bundle"
+  },
+  grant: {
+    id: "Activity.item.grant",
+    defaultMessage: "Grant"
   },
 })
diff --git a/src/services/Grant/GranteeType.js b/src/services/Grant/GranteeType.js
new file mode 100644
index 00000000..8a89034f
--- /dev/null
+++ b/src/services/Grant/GranteeType.js
@@ -0,0 +1,9 @@
+import { ActivityItemType }
+       from '../Activity/ActivityItemTypes/ActivityItemTypes'
+
+// Grantee types use the item type constants on the server
+export const GRANTEE_TYPE_USER = ActivityItemType.user
+
+export const GranteeType = Object.freeze({
+  user: GRANTEE_TYPE_USER,
+})
diff --git a/src/services/Project/GroupType/Messages.js b/src/services/Grant/Messages.js
similarity index 58%
rename from src/services/Project/GroupType/Messages.js
rename to src/services/Grant/Messages.js
index 14933a8b..fa6b7fd1 100644
--- a/src/services/Project/GroupType/Messages.js
+++ b/src/services/Grant/Messages.js
@@ -1,19 +1,19 @@
 import { defineMessages } from 'react-intl'
 
 /**
- * Internationalized messages for use with GroupType
+ * Internationalized messages for use with Role
  */
 export default defineMessages({
   admin: {
-    id: "Project.GroupType.admin",
+    id: "Grant.Role.admin",
     defaultMessage: "Admin",
   },
   write: {
-    id: "Project.GroupType.write",
+    id: "Grant.Role.write",
     defaultMessage: "Write",
   },
   read: {
-    id: "Project.GroupType.read",
+    id: "Grant.Role.read",
     defaultMessage: "Read",
   },
 })
diff --git a/src/services/Grant/Role.js b/src/services/Grant/Role.js
new file mode 100644
index 00000000..ab7feae6
--- /dev/null
+++ b/src/services/Grant/Role.js
@@ -0,0 +1,42 @@
+import _map from 'lodash/map'
+import _fromPairs from 'lodash/fromPairs'
+import _min from 'lodash/min'
+import messages from './Messages'
+
+// These constants are defined on the server
+export const ROLE_SUPERUSER = -1
+export const ROLE_ADMIN = 1
+export const ROLE_WRITE = 2
+export const ROLE_READ = 3
+
+export const Role = Object.freeze({
+  admin: ROLE_ADMIN,
+  write: ROLE_WRITE,
+  read: ROLE_READ,
+})
+
+/**
+ * Returns an object mapping role values to raw internationalized messages
+ * suitable for use with FormattedMessage or formatMessage
+ */
+export const messagesByRole = _fromPairs(
+  _map(messages, (message, key) => [Role[key], message])
+)
+
+/** Returns object containing localized labels  */
+export const roleLabels = intl => _fromPairs(
+  _map(messages, (message, key) => [key, intl.formatMessage(message)])
+)
+
+/** Returns the most privileged of the given roles **/
+export const mostPrivilegedRole = function(roles) {
+  return _min(roles)
+}
+
+/**
+ * Determines if the target role is implied by the given list of possessed
+ * roles
+ */
+export const rolesImply = function(targetRole, roles) {
+  return mostPrivilegedRole(roles) <= targetRole
+}
diff --git a/src/services/Grant/TargetType.js b/src/services/Grant/TargetType.js
new file mode 100644
index 00000000..cf4f0278
--- /dev/null
+++ b/src/services/Grant/TargetType.js
@@ -0,0 +1,9 @@
+import { ActivityItemType }
+       from '../Activity/ActivityItemTypes/ActivityItemTypes'
+
+// Target types use the item type constants on the server
+export const TARGET_TYPE_PROJECT = ActivityItemType.project
+
+export const TargetType = Object.freeze({
+  project: TARGET_TYPE_PROJECT,
+})
diff --git a/src/services/Project/GroupType/GroupType.js b/src/services/Project/GroupType/GroupType.js
deleted file mode 100644
index 0aa06937..00000000
--- a/src/services/Project/GroupType/GroupType.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import _map from 'lodash/map'
-import _fromPairs from 'lodash/fromPairs'
-import _min from 'lodash/min'
-import messages from './Messages'
-
-// These constants are defined on the server
-export const GROUP_TYPE_SUPERUSER = -1
-export const GROUP_TYPE_ADMIN = 1
-export const GROUP_TYPE_WRITE = 2
-export const GROUP_TYPE_READ = 3
-
-export const GroupType = Object.freeze({
-  admin: GROUP_TYPE_ADMIN,
-  write: GROUP_TYPE_WRITE,
-  read: GROUP_TYPE_READ,
-})
-
-/**
- * Returns an object mapping group type values to raw internationalized
- * messages suitable for use with FormattedMessage or formatMessage.
- */
-export const messagesByGroupType = _fromPairs(
-  _map(messages, (message, key) => [GroupType[key], message])
-)
-
-/** Returns object containing localized labels  */
-export const groupTypeLabels = intl => _fromPairs(
-  _map(messages, (message, key) => [key, intl.formatMessage(message)])
-)
-
-/** Returns the most privileged of the given group types **/
-export const mostPrivilegedGroupType = function(groupTypes) {
-  return _min(groupTypes)
-}
-
-/**
- * Determines if the target group type is implied by the given list of
- * possessed group types.
- */
-export const groupTypesImply = function(targetGroupType, groupTypes) {
-  return mostPrivilegedGroupType(groupTypes) <= targetGroupType
-}
diff --git a/src/services/Project/Project.js b/src/services/Project/Project.js
index 3a5836bb..0bdc69e3 100644
--- a/src/services/Project/Project.js
+++ b/src/services/Project/Project.js
@@ -12,7 +12,7 @@ import RequestStatus from '../Server/RequestStatus'
 import genericEntityReducer from '../Server/GenericEntityReducer'
 import { RECEIVE_CHALLENGES } from '../Challenge/ChallengeActions'
 import { RESULTS_PER_PAGE } from '../Search/Search'
-import { GroupType } from './GroupType/GroupType'
+import { Role } from '../Grant/Role'
 import { addServerError,
          addError } from '../Error/Error'
 import AppErrors from '../Error/AppErrors'
@@ -213,8 +213,8 @@ export const saveProject = function(projectData) {
 
       // If we just created the project, add the owner as an admin.
       if (areCreating && project) {
-        return setProjectManagerGroupType(
-          project.id, project.owner, true, GroupType.admin
+        return setProjectManagerRole(
+          project.id, project.owner, true, Role.admin
         )(dispatch).then(() => project)
       }
       else {
@@ -306,13 +306,13 @@ export const fetchProjectManagers = function(projectId) {
 }
 
 /**
- * Set group type (permissions) for user on project.
+ * Set role for user on project
  */
-export const setProjectManagerGroupType = function(projectId, userId, isOSMUserId, groupType) {
+export const setProjectManagerRole = function(projectId, userId, isOSMUserId, role) {
   return function(dispatch) {
     return new Endpoint(
       api.project.setManagerPermission, {
-        variables: {userId, projectId, groupType},
+        variables: {userId, projectId, role},
         params: {isOSMUserId: isOSMUserId ? 'true' : 'false'},
       }
     ).execute().then(rawManagers => {
@@ -341,9 +341,9 @@ export const setProjectManagerGroupType = function(projectId, userId, isOSMUserI
 
 /**
  * Add a user with the given OSM username to the given project with the given
- * group type (permissions).
+ * role
  */
-export const addProjectManager = function(projectId, username, groupType) {
+export const addProjectManager = function(projectId, username, role) {
   return function(dispatch) {
     return findUser(username).then(matchingUsers => {
       // We want an exact username match
@@ -351,7 +351,7 @@ export const addProjectManager = function(projectId, username, groupType) {
         _get(_find(matchingUsers, match => match.displayName === username), 'osmId')
 
       if (_isFinite(osmId)) {
-        return setProjectManagerGroupType(projectId, osmId, true, groupType)(dispatch)
+        return setProjectManagerRole(projectId, osmId, true, role)(dispatch)
       }
       else {
         dispatch(addError(AppErrors.user.notFound))
diff --git a/src/services/Server/APIRoutes.js b/src/services/Server/APIRoutes.js
index 12eb733b..c329343b 100644
--- a/src/services/Server/APIRoutes.js
+++ b/src/services/Server/APIRoutes.js
@@ -31,7 +31,7 @@ const apiRoutes = factory => {
       'activity': factory.get('/data/project/activity'),
       'managers': factory.get('/user/project/:projectId'),
       'comments': factory.get('/project/:id/comments'),
-      'setManagerPermission': factory.put('/user/:userId/project/:projectId/:groupType'),
+      'setManagerPermission': factory.put('/user/:userId/project/:projectId/:role'),
       'removeManager': factory.delete('/user/:userId/project/:projectId/-1'),
       'delete': factory.delete('/project/:id'),
       'addToVirtual': factory.post('/project/:projectId/challenge/:challengeId/add'),
diff --git a/src/services/Task/TaskStatus/TaskStatus.js b/src/services/Task/TaskStatus/TaskStatus.js
index 51ac7935..c99a49f1 100644
--- a/src/services/Task/TaskStatus/TaskStatus.js
+++ b/src/services/Task/TaskStatus/TaskStatus.js
@@ -83,7 +83,6 @@ export const allowedStatusProgressions = function(status, includeSelf = false) {
       progressions = new Set()
       break
     default:
-      console.log(`Unrecognized task status >>${status}<<`)
       throw new Error("unrecognized-task-status",
                       `Unrecognized task status ${status}`)
   }


Test patch:
diff --git a/src/interactions/User/AsEndUser.test.js b/src/interactions/User/AsEndUser.test.js
index d44e14c6..cd92fa45 100644
--- a/src/interactions/User/AsEndUser.test.js
+++ b/src/interactions/User/AsEndUser.test.js
@@ -1,13 +1,12 @@
 import AsEndUser from './AsEndUser'
 import { GUEST_USER_ID } from '../../services/User/User'
-import { GROUP_TYPE_SUPERUSER }
-       from '../../services/Project/GroupType/GroupType'
+import { ROLE_SUPERUSER } from '../../services/Grant/Role'
 
-const superGroup = {id: -1, groupType: GROUP_TYPE_SUPERUSER}
+const superUserGrant = {id: -1, role: ROLE_SUPERUSER}
 
-const guestUser = {id: GUEST_USER_ID, groups: []}
-const simpleUser = {id: 357, groups: []}
-const superUser = {id: 135, groups: [superGroup]}
+const guestUser = {id: GUEST_USER_ID, grants: []}
+const simpleUser = {id: 357, grants: []}
+const superUser = {id: 135, grants: [superUserGrant]}
 
 describe('isLoggedIn', () => {
   it("returns false if there is no user object", () => {
@@ -42,7 +41,7 @@ describe('isSuperUser', () => {
     expect(endUser.isSuperUser()).toBe(false)
   })
 
-  it("returns false if the user is a a normal end user", () => {
+  it("returns false if the user is a normal end user", () => {
     const endUser = AsEndUser(simpleUser)
     expect(endUser.isSuperUser()).toBe(false)
   })
diff --git a/src/interactions/User/AsManager.test.js b/src/interactions/User/AsManager.test.js
index 30eba365..25a72cc8 100644
--- a/src/interactions/User/AsManager.test.js
+++ b/src/interactions/User/AsManager.test.js
@@ -1,52 +1,56 @@
 import AsManager from './AsManager'
 import { GUEST_USER_ID } from '../../services/User/User'
-import { GroupType, GROUP_TYPE_SUPERUSER }
-       from '../../services/Project/GroupType/GroupType'
-
-const superGroup = {id: -1, groupType: GROUP_TYPE_SUPERUSER}
-const adminGroup123 = {id: 123, groupType: GroupType.admin}
-const readGroup456 = {id: 456, groupType: GroupType.read}
-const adminGroup789 = {id: 789, groupType: GroupType.admin}
-const writeGroup234 = {id: 234, groupType: GroupType.write}
-
-const powerUser = {id: 246, groups: [adminGroup123, readGroup456]}
-const writeUser = {id: 910, groups: [writeGroup234]}
-const superUser = {id: 135, groups: [superGroup]}
-const normalUser = {id: 790, osmProfile: {id: 987654321}, groups: []}
-
-const project123 = {id: 123, groups: [adminGroup123]}
-const project456 = {id: 456, groups: [readGroup456]}
-const project789 = {id: 789, groups: [adminGroup789]}
-const project101 = {id: 101, groups: [adminGroup789], owner: 987654321}
-const project102 = {id: 102, groups: [adminGroup123, writeGroup234, readGroup456]}
+import { Role, ROLE_SUPERUSER } from '../../services/Grant/Role'
+import { TargetType } from '../../services/Grant/TargetType'
+
+const superGrant = {id: -1, role: ROLE_SUPERUSER}
+const adminGrant123 = {id: 123, role: Role.admin, target: {objectType: TargetType.project, objectId: 123}}
+const readGrant456 = {id: 456, role: Role.read, target: {objectType: TargetType.project, objectId: 456}}
+const adminGrant789 = {id: 789, role: Role.admin, target: {objectType: TargetType.project, objectId: 789}}
+const adminGrant101 = {id: 101, role: Role.admin, target: {objectType: TargetType.project, objectId: 101}}
+const writeGrant234 = {id: 234, role: Role.write, target: {objectType: TargetType.project, objectId: 234}}
+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 powerUser = {id: 246, grants: [adminGrant123, readGrant456, adminGrant102, readGrant102]}
+const writeUser = {id: 910, grants: [writeGrant234, writeGrant102]}
+const superUser = {id: 135, grants: [superGrant]}
+const normalUser = {id: 790, osmProfile: {id: 987654321}, grants: []}
+
+const project123 = {id: 123, grants: [adminGrant123]}
+const project456 = {id: 456, grants: [readGrant456]}
+const project789 = {id: 789, grants: [adminGrant789]}
+const project101 = {id: 101, grants: [adminGrant101], owner: 987654321}
+const project102 = {id: 102, grants: [adminGrant102, writeGrant102, readGrant102]}
 
 const challenge123_1 = {id: 1231, parent: 123}
 const challenge123_2 = {id: 1232, parent: 123}
 const challenge456_1 = {id: 4561, parent: 456}
 const challenge789_1 = {id: 7891, parent: 789}
 
-describe('projectGroupTypes', () => {
-  it("returns the project group types possessed by the user", () => {
+describe('projectRoles', () => {
+  it("returns the project roles possessed by the user", () => {
     const manager = AsManager(powerUser)
 
-    const projectGroups = manager.projectGroupTypes(project102)
-    expect(projectGroups).toContain(GroupType.admin)
-    expect(projectGroups).toContain(GroupType.read)
-    expect(projectGroups).not.toContain(GroupType.write)
+    const roles = manager.projectRoles(project102)
+    expect(roles).toContain(Role.admin)
+    expect(roles).toContain(Role.read)
+    expect(roles).not.toContain(Role.write)
   })
 
-  it("includes the superuser group type for superusers", () => {
+  it("includes the superuser role for superusers", () => {
     const manager = AsManager(superUser)
 
-    const projectGroups = manager.projectGroupTypes(project102)
-    expect(projectGroups).toContain(GROUP_TYPE_SUPERUSER)
+    const roles = manager.projectRoles(project102)
+    expect(roles).toContain(ROLE_SUPERUSER)
   })
 
-  it("returns an empty list for a user possessing none of the project's group types", () => {
+  it("returns an empty list for a user possessing none of the project's roles", () => {
     const manager = AsManager(powerUser)
 
-    const projectGroups = manager.projectGroupTypes(project789)
-    expect(projectGroups).toHaveLength(0)
+    const roles = manager.projectRoles(project789)
+    expect(roles).toHaveLength(0)
   })
 })
 
@@ -58,7 +62,7 @@ describe('canManage', () => {
     expect(manager.canManage(project456)).toBe(true)
   })
 
-  it("returns true if the user possesses any of the project's groups", () => {
+  it("returns true if the user possesses any grant on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canManage(project123)).toBe(true)
@@ -66,17 +70,10 @@ describe('canManage', () => {
     expect(manager.canManage(project789)).toBe(false)
   })
 
-  it("returns true if the user is the project owner", () => {
+  it("returns false if the user is the project owner without grants on the project", () => {
     const manager = AsManager(normalUser)
 
-    expect(manager.canManage(project101)).toBe(true)
-    expect(manager.canManage(project789)).toBe(false)
-  })
-
-  it("returns false if user and project owner ids match but are are undefined", () => {
-    const manager = AsManager(powerUser)
-
-    expect(manager.canManage(project789)).toBe(false)
+    expect(manager.canManage(project101)).toBe(false)
   })
 
   it("returns false if the user is undefined", () => {
@@ -94,26 +91,19 @@ describe('canAdministrateProject', () => {
     expect(manager.canAdministrateProject(project456)).toBe(true)
   })
 
-  it("returns true if the user is the project owner", () => {
+  it("returns false if the user is the project owner without grants on the project", () => {
     const manager = AsManager(normalUser)
 
-    expect(manager.canAdministrateProject(project101)).toBe(true)
-    expect(manager.canAdministrateProject(project789)).toBe(false)
-  })
-
-  it("returns false if user and project owner ids match but are are undefined", () => {
-    const manager = AsManager(powerUser)
-
-    expect(manager.canAdministrateProject(project789)).toBe(false)
+    expect(manager.canAdministrateProject(project101)).toBe(false)
   })
 
-  it("returns true if the user possesses the project's admin group", () => {
+  it("returns true if the user possesses admin role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canAdministrateProject(project123)).toBe(true)
   })
 
-  it("returns false if the user merely contains non-admin project group", () => {
+  it("returns false if the user merely contains non-admin role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canAdministrateProject(project456)).toBe(false)
@@ -134,36 +124,29 @@ describe('canReadProject', () => {
     expect(manager.canReadProject(project456)).toBe(true)
   })
 
-  it("returns true if the user possesses the project's read group", () => {
+  it("returns true if the user possesses read role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canReadProject(project456)).toBe(true)
     expect(manager.canReadProject(project789)).toBe(false)
   })
 
-  it("returns true if the user possesses the project's write group", () => {
+  it("returns true if the user possesses write role on the project", () => {
     const manager = AsManager(writeUser)
 
     expect(manager.canReadProject(project102)).toBe(true)
   })
 
-  it("returns true if the user possesses the project's admin group", () => {
+  it("returns true if the user possesses admin role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canReadProject(project123)).toBe(true)
   })
 
-  it("returns true if the user is the project owner", () => {
+  it("returns false if the user is the project owner without grants on the project", () => {
     const manager = AsManager(normalUser)
 
-    expect(manager.canReadProject(project101)).toBe(true)
-    expect(manager.canReadProject(project789)).toBe(false)
-  })
-
-  it("returns false if user and project owner ids match but are are undefined", () => {
-    const manager = AsManager(powerUser)
-
-    expect(manager.canReadProject(project789)).toBe(false)
+    expect(manager.canReadProject(project101)).toBe(false)
   })
 
   it("returns false if the user is undefined", () => {
@@ -181,32 +164,25 @@ describe('canWriteProject', () => {
     expect(manager.canWriteProject(project456)).toBe(true)
   })
 
-  it("returns true if the user is the project owner", () => {
+  it("returns false if the user is the project owner without grants on the project", () => {
     const manager = AsManager(normalUser)
 
-    expect(manager.canWriteProject(project101)).toBe(true)
-    expect(manager.canWriteProject(project789)).toBe(false)
-  })
-
-  it("returns false if user and project owner ids match but are are undefined", () => {
-    const manager = AsManager(powerUser)
-
-    expect(manager.canWriteProject(project789)).toBe(false)
+    expect(manager.canWriteProject(project101)).toBe(false)
   })
 
-  it("returns true if the user possesses the project's admin group", () => {
+  it("returns true if the user possesses admin role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canWriteProject(project123)).toBe(true)
   })
 
-  it("returns true if the user possesses the project's write group", () => {
+  it("returns true if the user possesses write role on the project", () => {
     const manager = AsManager(writeUser)
 
     expect(manager.canWriteProject(project102)).toBe(true)
   })
 
-  it("returns false if the user merely contains the project's read group", () => {
+  it("returns false if the user merely contains read role on the project", () => {
     const manager = AsManager(powerUser)
 
     expect(manager.canWriteProject(project456)).toBe(false)