diff --git a/api/app/Enums/Role.php b/api/app/Enums/Role.php
new file mode 100644
index 00000000000..119dfe29877
--- /dev/null
+++ b/api/app/Enums/Role.php
@@ -0,0 +1,13 @@
+setRoleAssignmentsInputAttribute
+ // Do this by inserting the team id into each attach/detach/sync key of role assignments
+ $roleAssignments = $args['roleAssignments'];
+ foreach ($roleAssignments as $key => $value) {
+ $roleAssignments[$key]['team'] = $team->id;
+ }
+ $user->setRoleAssignmentsInputAttribute($roleAssignments);
+
+ return $team->fresh();
+ }
+}
diff --git a/api/app/GraphQL/Validators/TeamRolesInputValidator.php b/api/app/GraphQL/Validators/TeamRolesInputValidator.php
new file mode 100644
index 00000000000..c84b9260aa3
--- /dev/null
+++ b/api/app/GraphQL/Validators/TeamRolesInputValidator.php
@@ -0,0 +1,30 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'roles.*' => [
+ 'distinct',
+ Rule::exists('roles', 'id')->where(function ($query) {
+ return $query->where('is_team_based', true);
+ }),
+ ],
+ ];
+ }
+}
diff --git a/api/app/Policies/TeamPolicy.php b/api/app/Policies/TeamPolicy.php
index 6a919f2b1a7..3ad05241f6f 100644
--- a/api/app/Policies/TeamPolicy.php
+++ b/api/app/Policies/TeamPolicy.php
@@ -66,7 +66,6 @@ public function delete(User $user)
/**
* Determine whether the user can view a specific teams, team members.
- * Likely to be updated later to allow the team admin and teammates to view their own team
*
* @param \App\Models\Team/null $team
* @return \Illuminate\Auth\Access\Response|bool
@@ -75,4 +74,14 @@ public function viewTeamMembers(User $user, Team $team)
{
return $user->isAbleTo('view-any-teamMembers') || $user->isAbleTo('view-team-teamMembers', $team);
}
+
+ /**
+ * Determine whether the user can assign any user to this team (giving them any team-based role)
+ *
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function assignTeamMembers(User $user, Team $team)
+ {
+ return $user->isAbleTo('assign-any-role') || $user->isAbleTo('assign-any-teamRole') || $user->isAbleTo('assign-team-role', $team);
+ }
}
diff --git a/api/config/rolepermission.php b/api/config/rolepermission.php
index f1e28cb7c39..c71584d84c7 100644
--- a/api/config/rolepermission.php
+++ b/api/config/rolepermission.php
@@ -78,6 +78,7 @@
'role' => 'role',
'directiveForm' => 'directiveForm',
'applicantProfile' => 'applicantProfile',
+ 'teamRole' => 'teamRole',
],
/*
@@ -381,6 +382,10 @@
'en' => 'Update metadata associated with any Role',
'fr' => 'Mettre à jour des métadonnées associées à tout rôle',
],
+ 'assign-any-teamRole' => [
+ 'en' => 'Assign any user to any team, with any role.',
+ 'fr' => 'Affecter n\'importe quel utilisateur à n\'importe quelle équipe, avec n\'importe quel rôle.',
+ ],
'create-any-directiveForm' => [
'en' => 'Create any directive form',
@@ -474,6 +479,18 @@
'is_team_based' => false,
],
+ 'community_manager' => [
+ 'display_name' => [
+ 'en' => 'Community Manager',
+ 'fr' => 'Gestionnaire de communauté',
+ ],
+ 'description' => [
+ 'en' => 'Publishes pools, creates teams, and adds Pool Operators to teams.',
+ 'fr' => 'Publie des pools, crée des équipes et ajoute des opérateurs des bassins aux équipes.',
+ ],
+ 'is_team_based' => false,
+ ],
+
'platform_admin' => [
'display_name' => [
'en' => 'Platform Administrator',
@@ -625,6 +642,24 @@
],
],
+ 'community_manager' => [
+ 'userBasicInfo' => [
+ 'any' => ['view'],
+ ],
+ 'pool' => [
+ 'any' => ['view', 'publish'],
+ ],
+ 'teamMembers' => [
+ 'any' => ['view'],
+ ],
+ 'team' => [
+ 'any' => ['view', 'create', 'update', 'delete'],
+ ],
+ 'teamRole' => [
+ 'any' => ['assign'],
+ ],
+ ],
+
'platform_admin' => [
'classification' => [
'any' => ['create', 'update', 'delete'],
@@ -660,7 +695,7 @@
'any' => ['view'],
],
'team' => [
- 'any' => ['create', 'update', 'delete'],
+ 'any' => ['view', 'create', 'update', 'delete'],
],
'role' => [
'any' => ['view', 'assign'],
diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php
index 433ad6c51c7..037f5afa180 100644
--- a/api/database/factories/UserFactory.php
+++ b/api/database/factories/UserFactory.php
@@ -234,7 +234,7 @@ public function asRequestResponder()
/**
* Attach the pool operator role to a user after creation.
*
- * @param string $team Name of the team to attach the role to
+ * @param string|array $team Name of the team or teams to attach the role to
* @return $this
*/
public function asPoolOperator(string|array $team)
@@ -250,6 +250,18 @@ public function asPoolOperator(string|array $team)
});
}
+ /**
+ * Attach the Community Manager role to a user after creation.
+ *
+ * @return $this
+ */
+ public function asCommunityManager()
+ {
+ return $this->afterCreating(function (User $user) {
+ $user->addRole('community_manager');
+ });
+ }
+
/**
* Attach the admin role to a user after creation.
*
diff --git a/api/database/seeders/UserSeederLocal.php b/api/database/seeders/UserSeederLocal.php
index 0b173c0c31e..b7f90972827 100644
--- a/api/database/seeders/UserSeederLocal.php
+++ b/api/database/seeders/UserSeederLocal.php
@@ -23,6 +23,7 @@ public function run()
User::factory()
->asApplicant()
->asRequestResponder()
+ ->asCommunityManager()
->asAdmin()
->asPoolOperator(['digital-community-management', 'test-team'])
->withExperiences()
@@ -47,6 +48,18 @@ public function run()
'sub' => 'platform@test.com',
]);
+ User::factory()
+ ->asApplicant()
+ ->asCommunityManager()
+ ->withExperiences()
+ ->asGovEmployee()
+ ->create([
+ 'first_name' => 'Community',
+ 'last_name' => 'Manager',
+ 'email' => 'community@test.com',
+ 'sub' => 'community@test.com',
+ ]);
+
User::factory()
->asApplicant()
->asRequestResponder()
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 384dda9328b..a9ef7f38662 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -150,7 +150,7 @@ type PoolCandidateStatusChangedNotification implements Notification {
poolName: LocalizedString @rename(attribute: "pool_name")
}
-type UserPublicProfile {
+type UserPublicProfile @model(class: "\\App\\Models\\User") {
id: ID!
email: Email
firstName: String @rename(attribute: "first_name")
@@ -174,7 +174,7 @@ type Pool {
operationalRequirements: [OperationalRequirement]
@rename(attribute: "operational_requirements")
poolCandidates: [PoolCandidate]
- @hasMany(relation: "publishedPoolCandidates")
+ @hasMany(relation: "publishedPoolCandidates", scopes: ["authorizedToView"])
@can(ability: "view", resolved: true)
keyTasks: LocalizedString @rename(attribute: "key_tasks")
yourImpact: LocalizedString @rename(attribute: "your_impact")
@@ -651,6 +651,13 @@ type Query {
@deprecated(
reason: "applicants is deprecated. Use usersPaginated instead. Remove in #7652."
)
+ userPublicProfiles: [UserPublicProfile]!
+ @all
+ @guard
+ @can(ability: "viewBasicInfo", model: "User")
+ @deprecated(
+ reason: "We should avoid non-paginated queries when we know a query will return thousands of values. In this case, we must write an alternative first!"
+ )
# countApplicants returns the number of candidates matching its filters, and requires no special permissions.
countApplicants(where: ApplicantFilterInput): Int!
@count(model: "User", scopes: ["inITPublishingGroup"])
@@ -1380,6 +1387,25 @@ input RoleAssignmentHasMany {
detach: RolesInput
sync: RolesInput
}
+
+# This input only accepts Team-Based roles. It is assumed that a team id will be provided at a higher level of input.
+input RolesForTeamInput
+ @validator(class: "App\\GraphQL\\Validators\\TeamRolesInputValidator") {
+ roles: [ID!]
+}
+
+input RolesForTeamHasMany {
+ attach: RolesForTeamInput
+ detach: RolesForTeamInput
+ sync: RolesForTeamInput
+}
+
+input UpdateUserTeamRolesInput {
+ teamId: ID!
+ userId: UUID!
+ roleAssignments: RolesForTeamHasMany!
+}
+
input ScreeningResponseBelongsTo {
connect: ID!
}
@@ -1655,6 +1681,9 @@ type Mutation {
@delete
@guard
@can(ability: "delete", find: "id")
+ updateUserTeamRoles(
+ teamRoleAssignments: UpdateUserTeamRolesInput! @spread
+ ): Team @guard @can(ability: "assignTeamMembers", find: "teamId")
# Notifications
markNotificationAsRead(id: UUID!): Notification @guard # can only affect notifications belonging to the logged-in user
diff --git a/api/storage/app/lighthouse-schema.graphql b/api/storage/app/lighthouse-schema.graphql
index 29beada6e1d..67ffbb97465 100755
--- a/api/storage/app/lighthouse-schema.graphql
+++ b/api/storage/app/lighthouse-schema.graphql
@@ -12,6 +12,7 @@ type Query {
): [User]! @deprecated(reason: "users is deprecated. Use usersPaginated instead. Remove in #7660.")
applicant(id: UUID!): User @deprecated(reason: "applicant is deprecated. Use user instead. Remove in #7651.")
applicants(includeIds: [ID]!): [User]! @deprecated(reason: "applicants is deprecated. Use usersPaginated instead. Remove in #7652.")
+ userPublicProfiles: [UserPublicProfile]! @deprecated(reason: "We should avoid non-paginated queries when we know a query will return thousands of values. In this case, we must write an alternative first!")
countApplicants(where: ApplicantFilterInput): Int!
pool(id: UUID!): Pool
publishedPools(closingAfter: DateTime, publishingGroup: PublishingGroup): [Pool!]!
@@ -132,6 +133,7 @@ type Mutation {
createTeam(team: CreateTeamInput!): Team
updateTeam(id: UUID!, team: UpdateTeamInput!): Team
deleteTeam(id: UUID!): Team
+ updateUserTeamRoles(teamRoleAssignments: UpdateUserTeamRolesInput!): Team
markNotificationAsRead(id: UUID!): Notification
createUserSkill(userId: UUID!, skillId: UUID!, userSkill: CreateUserSkillInput): UserSkill
updateUserSkill(id: UUID!, userSkill: UpdateUserSkillInput): UserSkill
@@ -1158,6 +1160,22 @@ input RoleAssignmentHasMany {
sync: RolesInput
}
+input RolesForTeamInput {
+ roles: [ID!]
+}
+
+input RolesForTeamHasMany {
+ attach: RolesForTeamInput
+ detach: RolesForTeamInput
+ sync: RolesForTeamInput
+}
+
+input UpdateUserTeamRolesInput {
+ teamId: ID!
+ userId: UUID!
+ roleAssignments: RolesForTeamHasMany!
+}
+
input ScreeningResponseBelongsTo {
connect: ID!
}
diff --git a/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php b/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php
new file mode 100644
index 00000000000..ed650df8f05
--- /dev/null
+++ b/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php
@@ -0,0 +1,258 @@
+seed(RolePermissionSeeder::class);
+ $this->bootRefreshesSchemaCache();
+
+ $this->applicant = User::factory()
+ ->asApplicant()
+ ->create([
+ 'email' => 'applicant-user@test.com',
+ 'sub' => 'applicant-user@test.com',
+ ]);
+
+ $this->adminUser = User::factory()
+ ->asApplicant()
+ ->asRequestResponder()
+ ->asAdmin()
+ ->create([
+ 'email' => 'admin-user@test.com',
+ 'sub' => 'admin-user@test.com',
+ ]);
+
+ $this->team = Team::factory()->create();
+
+ $this->poolOperatorId = Role::where('name', EnumsRole::POOL_OPERATOR)->first()->id;
+ }
+
+ public function testAdminCanAttachUserToTeam()
+ {
+ $this->actingAs($this->adminUser, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ }
+ }
+ ',
+ [
+
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $this->applicant->id,
+ 'roleAssignments' => [
+ 'attach' => [
+ 'roles' => [$this->poolOperatorId],
+ ],
+ ],
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team));
+ }
+
+ public function testAdminCanDetachUserFromTeam()
+ {
+ $this->applicant->addRole(EnumsRole::POOL_OPERATOR, $this->team);
+ $this->actingAs($this->adminUser, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ }
+ }
+ ',
+ [
+
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $this->applicant->id,
+ 'roleAssignments' => [
+ 'detach' => [
+ 'roles' => [$this->poolOperatorId],
+ ],
+ ],
+ ],
+ ]
+ );
+ $this->assertFalse($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team));
+ }
+
+ public function testAdminCanSyncUserToAndFromTeam()
+ {
+ $this->actingAs($this->adminUser, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ }
+ }
+ ',
+ [
+
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $this->applicant->id,
+ 'roleAssignments' => [
+ 'sync' => [
+ 'roles' => [$this->poolOperatorId],
+ ],
+ ],
+ ],
+ ]
+ );
+ $this->assertTrue($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team));
+
+ $this->actingAs($this->adminUser, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ }
+ }
+ ',
+ [
+
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $this->applicant->id,
+ 'roleAssignments' => [
+ 'sync' => [
+ 'roles' => [],
+ ],
+ ],
+ ],
+ ]
+ );
+ $this->assertFalse($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team));
+ }
+
+ // Create several users with different roles. Assert that an admin can see the users in each role.
+ public function testAllTeamMembersCanBeReadFromResponse()
+ {
+ $otherUser = User::factory()->asPoolOperator($this->team->name)->create();
+ $this->actingAs($this->adminUser, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ roleAssignments {
+ role { name }
+ user { id }
+ }
+ }
+ }
+ ',
+ [
+
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $this->applicant->id,
+ 'roleAssignments' => [
+ 'attach' => [
+ 'roles' => [$this->poolOperatorId],
+ ],
+ ],
+ ],
+ ]
+ )->assertJson([
+ 'data' => [
+ 'updateUserTeamRoles' => [
+ 'id' => $this->team->id,
+ 'roleAssignments' => [
+ [
+
+ 'role' => [
+ 'name' => EnumsRole::POOL_OPERATOR->value,
+ ],
+ 'user' => [
+ 'id' => $otherUser->id,
+ ],
+ ],
+ [
+ 'role' => [
+ 'name' => EnumsRole::POOL_OPERATOR->value,
+ ],
+ 'user' => [
+ 'id' => $this->applicant->id,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testOnlyAdminsAndCommunityManagersHavePermissionEvenToEditSelf()
+ {
+ $permissionsMap = [
+ EnumsRole::BASE_USER->value => false,
+ EnumsRole::APPLICANT->value => false,
+ EnumsRole::POOL_OPERATOR->value => false,
+ EnumsRole::REQUEST_RESPONDER->value => false,
+ EnumsRole::COMMUNITY_MANAGER->value => true,
+ EnumsRole::PLATFORM_ADMIN->value => true,
+ ];
+ foreach ($permissionsMap as $role => $hasPermission) {
+ $user = User::factory()->create();
+ $user->addRole($role);
+ $response = $this->actingAs($user, 'api')->graphQL(
+ /** @lang GraphQL */
+ '
+ mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) {
+ updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) {
+ id
+ }
+ }
+ ',
+ [
+ 'teamRoleAssignments' => [
+ 'teamId' => $this->team->id,
+ 'userId' => $user->id,
+ 'roleAssignments' => [
+ 'attach' => [
+ 'roles' => [$this->poolOperatorId],
+ ],
+ ],
+ ],
+ ]
+ );
+ if ($hasPermission) {
+ $response->assertGraphQLErrorFree();
+ } else {
+ $response->assertGraphQLErrorMessage('This action is unauthorized.');
+ }
+ }
+ }
+}
diff --git a/api/tests/Feature/RolePermissionTest.php b/api/tests/Feature/RolePermissionTest.php
index 489d2824883..53399b136ab 100644
--- a/api/tests/Feature/RolePermissionTest.php
+++ b/api/tests/Feature/RolePermissionTest.php
@@ -61,7 +61,7 @@ public function test_guest_role()
'create-any-searchRequest',
'view-any-team',
'view-any-role',
- ], true));
+ ], true)); // The `true` as a second argument means user must have ALL permissions, instead of just one.
$this->assertFalse(($this->user->isAbleTo('view-any-user')));
@@ -227,6 +227,33 @@ public function test_platform_admin_role()
$this->cleanup();
}
+ /**
+ * Test the Community Manager Role
+ *
+ * @return void
+ */
+ public function test_community_manager_role()
+ {
+ $communityManager = Role::where('name', 'community_manager')->sole();
+ $this->user->addRole($communityManager);
+
+ $permissionsToCheck = [
+ 'view-any-userBasicInfo',
+ 'view-any-pool',
+ 'publish-any-pool',
+ 'view-any-teamMembers',
+ 'create-any-team',
+ 'update-any-team',
+ 'delete-any-team',
+ 'assign-any-teamRole',
+ ];
+
+ $this->assertTrue($this->user->hasRole('community_manager'));
+ $this->assertTrue($this->user->isAbleTo($permissionsToCheck, true));
+
+ $this->cleanup();
+ }
+
/**
* Test Strict Team Check
*
diff --git a/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx b/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx
index 1348fda22a7..fdbe88566cc 100644
--- a/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx
+++ b/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx
@@ -44,6 +44,7 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => {
roles: [
ROLE_NAME.PoolOperator,
ROLE_NAME.RequestResponder,
+ ROLE_NAME.CommunityManager,
ROLE_NAME.PlatformAdmin,
],
text: intl.formatMessage({
@@ -56,7 +57,11 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => {
key: "pools",
href: paths.poolTable(),
icon: Squares2X2Icon,
- roles: [ROLE_NAME.PoolOperator, ROLE_NAME.PlatformAdmin],
+ roles: [
+ ROLE_NAME.PoolOperator,
+ ROLE_NAME.CommunityManager,
+ ROLE_NAME.PlatformAdmin,
+ ],
text: intl.formatMessage(adminMessages.pools),
},
{
@@ -84,7 +89,11 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => {
key: "teams",
href: paths.teamTable(),
icon: BuildingOffice2Icon,
- roles: [ROLE_NAME.PoolOperator, ROLE_NAME.PlatformAdmin],
+ roles: [
+ ROLE_NAME.PoolOperator,
+ ROLE_NAME.CommunityManager,
+ ROLE_NAME.PlatformAdmin,
+ ],
text: intl.formatMessage(adminMessages.teams),
},
{
diff --git a/apps/web/src/components/Layout/Layout.tsx b/apps/web/src/components/Layout/Layout.tsx
index b21da0513bf..d2aa0733719 100644
--- a/apps/web/src/components/Layout/Layout.tsx
+++ b/apps/web/src/components/Layout/Layout.tsx
@@ -78,6 +78,7 @@ const Layout = () => {
[
ROLE_NAME.PoolOperator,
ROLE_NAME.RequestResponder,
+ ROLE_NAME.CommunityManager,
ROLE_NAME.PlatformAdmin,
].some(
(authorizedRoleName) => userRoleNames?.includes(authorizedRoleName),
diff --git a/apps/web/src/components/Router.tsx b/apps/web/src/components/Router.tsx
index 51df45ce074..e9b5768fe57 100644
--- a/apps/web/src/components/Router.tsx
+++ b/apps/web/src/components/Router.tsx
@@ -1219,6 +1219,7 @@ const createRoute = (
roles={[
ROLE_NAME.PoolOperator,
ROLE_NAME.RequestResponder,
+ ROLE_NAME.CommunityManager,
ROLE_NAME.PlatformAdmin,
]}
loginPath={loginPath}
@@ -1323,7 +1324,11 @@ const createRoute = (
index: true,
element: (