From 90bfd942886a0aa44b906135f09db45c6a848703 Mon Sep 17 00:00:00 2001
From: tristan-orourke
Date: Fri, 20 Oct 2023 10:24:57 -0400
Subject: [PATCH] [Feature] new Community Manager role (#8094)
* Add community manager to rolepermission seeder
* Give community managers ability to assign any team role
this will work better long term, AND be easier to implement
* create assignUserToTeam mutation
* Update mutation to affect only single user and single team
* Use User query with reduced scope, and don't for it to render table
Instead of Users query, get UserPublicProfiles, a safer query available to more user roles, and sufficient since we only need names. Also, don't wait for the potentially long-running user query to render the table, since its only required for adding new users to team.
* Abuse useMemo to avoid infinite re-rendering
* Show loading message on disabled Select until users are loaded
* Test community-manager role permissions
* Add PHPUnit tests for new mutation
* Check in Role enum
* Allow Community Manager to access some admin pages
* Seed comunity manager
* Reveal relevant links to Community Mngrs, on Admin
* Seed PlatformAdmin with all roles
* ensure authorizedToView scope doesn't block viewing of basic info
* Allow Community Managers to view all pools
* Use new mutation when editing roles on Team Members page
* Add french translation
* Remove unused variables
* Reverse change to user scopeAuthorizedToView
* Fix form input id
* Combine mutation arguments
* Add authorizedToView scope so Community Manager can view Pool pages (when not allowed to view candidates)
* Allow Community Managers to publish pools
* ACTUALLY allow Community Managers to publish pools
* Run PHP linter
---------
Co-authored-by: tristan-orourke
---
api/app/Enums/Role.php | 13 +
.../GraphQL/Mutations/UpdateUserTeamRoles.php | 30 ++
.../Validators/TeamRolesInputValidator.php | 30 ++
api/app/Policies/TeamPolicy.php | 11 +-
api/config/rolepermission.php | 37 ++-
api/database/factories/UserFactory.php | 14 +-
api/database/seeders/UserSeederLocal.php | 13 +
api/graphql/schema.graphql | 33 ++-
api/storage/app/lighthouse-schema.graphql | 18 ++
.../Mutation/UpdateUserTeamRolesTest.php | 258 ++++++++++++++++++
api/tests/Feature/RolePermissionTest.php | 29 +-
.../AdminSideMenu/AdminSideMenu.tsx | 13 +-
apps/web/src/components/Layout/Layout.tsx | 1 +
apps/web/src/components/Router.tsx | 34 ++-
apps/web/src/lang/fr.json | 4 +
.../AdminDashboardPage/AdminDashboardPage.tsx | 21 ++
.../IndexPoolPage/components/PoolTable.tsx | 3 +-
.../pages/Pools/ViewPoolPage/ViewPoolPage.tsx | 22 +-
.../Teams/TeamMembersPage/TeamMembersPage.tsx | 130 +++++----
.../components/AddTeamMemberDialog.tsx | 43 +--
.../components/EditTeamMemberDialog.tsx | 22 +-
.../components/RemoveTeamMemberDialog.tsx | 12 +-
.../Teams/TeamMembersPage/components/types.ts | 6 +-
.../src/pages/Teams/teamsOperations.graphql | 23 ++
packages/auth/src/const.ts | 1 +
packages/i18n/src/lang/fr.json | 4 +
packages/i18n/src/messages/commonMessages.ts | 6 +
27 files changed, 713 insertions(+), 118 deletions(-)
create mode 100644 api/app/Enums/Role.php
create mode 100644 api/app/GraphQL/Mutations/UpdateUserTeamRoles.php
create mode 100644 api/app/GraphQL/Validators/TeamRolesInputValidator.php
create mode 100644 api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php
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: (
@@ -1334,7 +1339,10 @@ const createRoute = (
path: "create",
element: (
@@ -1345,7 +1353,11 @@ const createRoute = (
path: ":teamId",
element: (
@@ -1358,6 +1370,7 @@ const createRoute = (
@@ -1383,6 +1399,7 @@ const createRoute = (
@@ -1427,6 +1448,7 @@ const createRoute = (
roles={[
ROLE_NAME.PoolOperator,
ROLE_NAME.RequestResponder,
+ ROLE_NAME.CommunityManager,
ROLE_NAME.PlatformAdmin,
]}
loginPath={loginPath}
@@ -1442,6 +1464,7 @@ const createRoute = (
roles={[
ROLE_NAME.PoolOperator,
ROLE_NAME.RequestResponder,
+ ROLE_NAME.CommunityManager,
ROLE_NAME.PlatformAdmin,
]}
loginPath={loginPath}
@@ -1456,6 +1479,7 @@ const createRoute = (
{
]}
/>
)}
+ {hasRole("community_manager", roleAssignments) && (
+
+ )}
{hasRole("platform_admin", roleAssignments) && (
{
const pools = unpackMaybes(data?.pools).filter((pool) => {
if (
hasRole(ROLE_NAME.PlatformAdmin, roleAssignments) ||
- hasRole(ROLE_NAME.RequestResponder, roleAssignments)
+ hasRole(ROLE_NAME.RequestResponder, roleAssignments) ||
+ hasRole(ROLE_NAME.CommunityManager, roleAssignments)
) {
return true;
}
diff --git a/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx b/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx
index 1e13aa017a3..6df403b158a 100644
--- a/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx
+++ b/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx
@@ -73,7 +73,10 @@ export const ViewPool = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const assessmentBadge = getPoolCompletenessBadge(assessmentStatus);
const processBadge = getProcessStatusBadge(pool.status);
- const isAdmin = checkRole([ROLE_NAME.PlatformAdmin], roleAssignments);
+ const canPublish = checkRole(
+ [ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin],
+ roleAssignments,
+ );
let closingDate = "";
if (pool.closingDate) {
@@ -341,7 +344,7 @@ export const ViewPool = ({
)}
- {!isAdmin && pool.status === PoolStatus.Draft && (
+ {!canPublish && pool.status === PoolStatus.Draft && (
)}
{[PoolStatus.Closed, PoolStatus.Published].includes(
@@ -377,14 +380,13 @@ export const ViewPool = ({
onDelete={onDelete}
/>
)}
- {pool.status === PoolStatus.Draft &&
- checkRole([ROLE_NAME.PlatformAdmin], roleAssignments) && (
-
- )}
+ {pool.status === PoolStatus.Draft && canPublish && (
+
+ )}
diff --git a/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx b/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx
index 239607377f6..b46a99eac4d 100644
--- a/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx
+++ b/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx
@@ -6,15 +6,17 @@ import { useParams } from "react-router-dom";
import { Heading, Pending, ThrowNotFound } from "@gc-digital-talent/ui";
import { getLocalizedName } from "@gc-digital-talent/i18n";
import { notEmpty } from "@gc-digital-talent/helpers";
+
+import SEO from "~/components/SEO/SEO";
import {
- useTeamMembersQuery,
Role,
Scalars,
Team,
- User,
-} from "@gc-digital-talent/graphql";
-
-import SEO from "~/components/SEO/SEO";
+ useAllUsersNamesQuery,
+ useGetTeamQuery,
+ useListRolesQuery,
+ UserPublicProfile,
+} from "~/api/generated";
import { getFullNameLabel } from "~/utils/nameUtils";
import { groupRoleAssignmentsByUser, TeamMember } from "~/utils/teamUtils";
import useRoutes from "~/hooks/useRoutes";
@@ -29,7 +31,7 @@ const columnHelper = createColumnHelper();
interface TeamMembersProps {
members: Array;
- availableUsers: Array;
+ availableUsers: Array | null;
roles: Array;
team: Team;
}
@@ -142,64 +144,82 @@ const TeamMembersPage = () => {
const intl = useIntl();
const routes = useRoutes();
const { teamId } = useParams();
- const [{ data, fetching, error }] = useTeamMembersQuery({
- variables: { id: teamId || "" },
+ const [{ data, fetching, error }] = useGetTeamQuery({
+ variables: { teamId: teamId || "" },
});
+ const [{ data: rolesData, fetching: rolesFetching, error: rolesError }] =
+ useListRolesQuery();
+ const [{ data: userData, error: userError }] = useAllUsersNamesQuery();
const team = data?.team;
- const roles = React.useMemo(() => {
- return data?.roles.filter(notEmpty).filter((role) => role.isTeamBased);
- }, [data?.roles]);
- const users = React.useMemo(() => {
- return groupRoleAssignmentsByUser(data?.team?.roleAssignments || []);
- }, [data?.team?.roleAssignments]);
- const availableUsers = data?.users
- ?.filter(notEmpty)
- .filter((user) => !users.find((teamUser) => teamUser.id === user?.id));
-
- const navigationCrumbs = [
- {
- label: intl.formatMessage({
- defaultMessage: "Home",
- id: "EBmWyo",
- description: "Link text for the home link in breadcrumbs.",
- }),
- url: routes.adminDashboard(),
- },
- {
- label: intl.formatMessage(adminMessages.teams),
- url: routes.teamTable(),
- },
- ...(teamId
- ? [
- {
- label: getLocalizedName(data?.team?.displayName, intl),
- url: routes.teamView(teamId),
- },
- ]
- : []),
- ...(teamId
- ? [
- {
- label: intl.formatMessage({
- defaultMessage: "Members",
- id: "nfZQ89",
- description: "Breadcrumb title for the team members page link.",
- }),
- url: routes.teamMembers(teamId),
- },
- ]
- : []),
- ];
+ const roles = React.useMemo(
+ () =>
+ rolesData?.roles
+ ? rolesData.roles.filter(notEmpty).filter((role) => role.isTeamBased)
+ : [],
+ [rolesData?.roles],
+ );
+ const users = React.useMemo(
+ () => groupRoleAssignmentsByUser(data?.team?.roleAssignments || []),
+ [data?.team?.roleAssignments],
+ );
+ const availableUsers = React.useMemo(
+ () =>
+ userData?.userPublicProfiles
+ ?.filter(notEmpty)
+ .filter((user) => !users.find((teamUser) => teamUser.id === user?.id)),
+ [userData?.userPublicProfiles, users],
+ );
+
+ const navigationCrumbs = React.useMemo(
+ () => [
+ {
+ label: intl.formatMessage({
+ defaultMessage: "Home",
+ id: "EBmWyo",
+ description: "Link text for the home link in breadcrumbs.",
+ }),
+ url: routes.adminDashboard(),
+ },
+ {
+ label: intl.formatMessage(adminMessages.teams),
+ url: routes.teamTable(),
+ },
+ ...(teamId
+ ? [
+ {
+ label: getLocalizedName(data?.team?.displayName, intl),
+ url: routes.teamView(teamId),
+ },
+ ]
+ : []),
+ ...(teamId
+ ? [
+ {
+ label: intl.formatMessage({
+ defaultMessage: "Members",
+ id: "nfZQ89",
+ description: "Breadcrumb title for the team members page link.",
+ }),
+ url: routes.teamMembers(teamId),
+ },
+ ]
+ : []),
+ ],
+ [data?.team?.displayName, intl, routes, teamId],
+ );
return (
-
+
{team && users ? (
) : (
diff --git a/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx b/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx
index dab767e9db7..6c8fc791251 100644
--- a/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx
+++ b/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx
@@ -17,8 +17,8 @@ import {
import {
Role,
Team,
- User,
- useUpdateUserAsAdminMutation,
+ UserPublicProfile,
+ useUpdateUserTeamRolesMutation,
} from "~/api/generated";
import { getFullNameLabel } from "~/utils/nameUtils";
@@ -27,7 +27,7 @@ import { getTeamBasedRoleOptions } from "./utils";
interface AddTeamMemberDialogProps {
team: Team;
- availableUsers: Array;
+ availableUsers: Array | null;
availableRoles: Array;
}
@@ -38,14 +38,14 @@ const AddTeamMemberDialog = ({
}: // onSave,
AddTeamMemberDialogProps) => {
const intl = useIntl();
- const [, executeMutation] = useUpdateUserAsAdminMutation();
+ const [, executeMutation] = useUpdateUserTeamRolesMutation();
const [isOpen, setIsOpen] = React.useState(false);
const methods = useForm({
defaultValues: {
- user: "",
- team: team.id,
- teamDisplay: team.id,
+ userId: "",
+ teamId: team.id,
+ teamDisplay: team.id, // This form field will be disabled and only used for display purposes.
roles: [],
},
});
@@ -57,12 +57,12 @@ AddTeamMemberDialogProps) => {
const handleSave = async (formValues: TeamMemberFormValues) => {
await executeMutation({
- id: formValues.user,
- user: {
- roleAssignmentsInput: {
+ teamRoleAssignments: {
+ userId: formValues.userId,
+ teamId: formValues.teamId,
+ roleAssignments: {
attach: {
roles: formValues.roles,
- team: formValues.team,
},
},
},
@@ -93,7 +93,7 @@ AddTeamMemberDialogProps) => {
};
const roleOptions = getTeamBasedRoleOptions(availableRoles, intl);
- const userOptions = availableUsers.map((user) => ({
+ const userOptions = availableUsers?.map((user) => ({
value: user.id,
label: getFullNameLabel(user.firstName, user.lastName, intl),
}));
@@ -104,6 +104,8 @@ AddTeamMemberDialogProps) => {
description: "Label for the add member to team form",
});
+ const fetchingUsers = availableUsers === null;
+
return (
@@ -131,11 +133,14 @@ AddTeamMemberDialogProps) => {
data-h2-gap="base(x1 0)"
>