From 6d6e5396c03fe7f46b2aef2891c10d4599b8d3a7 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 23 Jul 2024 08:48:31 +0700 Subject: [PATCH] Bulk-add users to existing org (#887) * Add GQL mutation for bulk-adding org members Most of this code is identical to the bulk-add code in ProjectMutations, but the org version explicitly does *not* invite members if an email address is not found. Instead, any usernames or emails not found are returned to the frontend so that the user can take appropriate action. * Add frontend UI to bulk-add members to orgs * Fix GraphQL invalidation Since the org page query asks for `orgById`, the GraphQL cache is caching the type OrgById rather than the type Organization. While we're at it, we also invalidate the OrgById cache for a few other mutations like adding and removing members from an org. And we stop asking for the mutation to return the list of org members, because the org admins don't have permission to access the .members field of orgs in normal queries, only in the orgById query. This stops the GraphQL permissions error that was popping up when org admins tried to do these operations. --------- Co-authored-by: Kevin Hahn --- backend/LexBoxApi/GraphQL/OrgMutations.cs | 48 ++++++ .../LexBoxApi/Services/PermissionService.cs | 5 + .../ServiceInterfaces/IPermissionService.cs | 1 + frontend/schema.graphql | 29 ++++ .../components/Badges/OrgMemberBadge.svelte | 25 +++ frontend/src/lib/gql/gql-client.ts | 12 ++ frontend/src/lib/i18n/locales/en.json | 15 ++ .../(authenticated)/org/[org_id]/+page.svelte | 2 + .../(authenticated)/org/[org_id]/+page.ts | 57 ++++--- .../org/[org_id]/BulkAddOrgMembers.svelte | 158 ++++++++++++++++++ 10 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/components/Badges/OrgMemberBadge.svelte create mode 100644 frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index e7d6ae56e..f76cc5080 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -196,6 +196,54 @@ private async Task UpdateOrgMemberRole(LexBoxDbContext dbContext, Organization o await dbContext.SaveChangesAsync(); } + public record BulkAddOrgMembersInput(Guid OrgId, string[] Usernames, OrgRole Role); + public record OrgMemberRole(string Username, OrgRole Role); + public record BulkAddOrgMembersResult(List AddedMembers, List NotFoundMembers, List ExistingMembers); + + [Error] + [Error] + [Error] + [UseMutationConvention] + public async Task BulkAddOrgMembers( + BulkAddOrgMembersInput input, + IPermissionService permissionService, + LexBoxDbContext dbContext) + { + permissionService.AssertCanEditOrg(input.OrgId); + var orgExists = await dbContext.Orgs.AnyAsync(o => o.Id == input.OrgId); + if (!orgExists) throw NotFoundException.ForType(); + List AddedMembers = []; + List ExistingMembers = []; + List NotFoundMembers = []; + var existingUsers = await dbContext.Users.Include(u => u.Organizations).Where(u => input.Usernames.Contains(u.Username) || input.Usernames.Contains(u.Email)).ToArrayAsync(); + var byUsername = existingUsers.Where(u => u.Username is not null).ToDictionary(u => u.Username!); + var byEmail = existingUsers.Where(u => u.Email is not null).ToDictionary(u => u.Email!); + foreach (var usernameOrEmail in input.Usernames) + { + var user = byUsername.GetValueOrDefault(usernameOrEmail) ?? byEmail.GetValueOrDefault(usernameOrEmail); + if (user is null) + { + NotFoundMembers.Add(new OrgMemberRole(usernameOrEmail, input.Role)); + } + else + { + var userOrg = user.Organizations.FirstOrDefault(p => p.OrgId == input.OrgId); + if (userOrg is not null) + { + ExistingMembers.Add(new OrgMemberRole(user.Username ?? user.Email!, userOrg.Role)); + } + else + { + AddedMembers.Add(new OrgMemberRole(user.Username ?? user.Email!, input.Role)); + // Not yet a member, so add a membership. We don't want to touch existing memberships, which might have other roles + user.Organizations.Add(new OrgMember { Role = input.Role, OrgId = input.OrgId, UserId = user.Id }); + } + } + } + await dbContext.SaveChangesAsync(); + return new BulkAddOrgMembersResult(AddedMembers, NotFoundMembers, ExistingMembers); + } + [Error] [Error] [Error] diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index d83a7fa42..661e30fbd 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -164,6 +164,11 @@ public bool CanEditOrg(Guid orgId) return false; } + public void AssertCanEditOrg(Guid orgId) + { + if (!CanEditOrg(orgId)) throw new UnauthorizedAccessException(); + } + public void AssertCanEditOrg(Organization org) { if (!CanEditOrg(org.Id)) throw new UnauthorizedAccessException(); diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index e63271ced..7a626ddb1 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -34,5 +34,6 @@ public interface IPermissionService bool IsOrgMember(Guid orgId); bool CanEditOrg(Guid orgId); void AssertCanEditOrg(Organization org); + void AssertCanEditOrg(Guid orgId); void AssertCanAddProjectToOrg(Organization org); } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 2f5792744..858db9aa1 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -31,6 +31,17 @@ type AuthUserProject { projectId: UUID! } +type BulkAddOrgMembersPayload { + bulkAddOrgMembersResult: BulkAddOrgMembersResult + errors: [BulkAddOrgMembersError!] +} + +type BulkAddOrgMembersResult { + addedMembers: [OrgMemberRole!]! + notFoundMembers: [OrgMemberRole!]! + existingMembers: [OrgMemberRole!]! +} + type BulkAddProjectMembersPayload { bulkAddProjectMembersResult: BulkAddProjectMembersResult errors: [BulkAddProjectMembersError!] @@ -208,6 +219,7 @@ type Mutation { removeProjectFromOrg(input: RemoveProjectFromOrgInput!): RemoveProjectFromOrgPayload! setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! + bulkAddOrgMembers(input: BulkAddOrgMembersInput!): BulkAddOrgMembersPayload! changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! createProject(input: CreateProjectInput!): CreateProjectPayload! @authorize(policy: "VerifiedEmailRequiredPolicy") addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! @@ -293,6 +305,11 @@ type OrgMemberDtoCreatedBy { name: String! } +type OrgMemberRole { + username: String! + role: OrgRole! +} + type OrgProjects { org: Organization! project: Project! @@ -418,6 +435,10 @@ type SoftDeleteProjectPayload { errors: [SoftDeleteProjectError!] } +type UnauthorizedAccessError implements Error { + message: String! +} + type UniqueValueError implements Error { message: String! } @@ -462,6 +483,8 @@ union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVeri union AddProjectToOrgError = DbError | NotFoundError +union BulkAddOrgMembersError = NotFoundError | DbError | UnauthorizedAccessError + union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError union ChangeOrgMemberRoleError = DbError | NotFoundError @@ -518,6 +541,12 @@ input BooleanOperationFilterInput { neq: Boolean } +input BulkAddOrgMembersInput { + orgId: UUID! + usernames: [String!]! + role: OrgRole! +} + input BulkAddProjectMembersInput { projectId: UUID usernames: [String!]! diff --git a/frontend/src/lib/components/Badges/OrgMemberBadge.svelte b/frontend/src/lib/components/Badges/OrgMemberBadge.svelte new file mode 100644 index 000000000..c9894fd3b --- /dev/null +++ b/frontend/src/lib/components/Badges/OrgMemberBadge.svelte @@ -0,0 +1,25 @@ + + + + + {member.name} + + + + + + + + + diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index d4f79fcd0..0d0cabb4a 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -28,6 +28,9 @@ import { type BulkAddProjectMembersMutationVariables, type DeleteDraftProjectMutationVariables, type MutationAddProjectToOrgArgs, + type BulkAddOrgMembersMutationVariables, + type ChangeOrgMemberRoleMutationVariables, + type AddOrgMemberMutationVariables, } from './types'; import type {Readable, Unsubscriber} from 'svelte/store'; import {derived} from 'svelte/store'; @@ -70,6 +73,15 @@ function createGqlClient(_gqlEndpoint?: string): Client { cache.invalidate({__typename: 'Project', id: args.input.projectId}); } }, + bulkAddOrgMembers: (result, args: BulkAddOrgMembersMutationVariables, cache, _info) => { + cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); + }, + changeOrgMemberRole: (result, args: ChangeOrgMemberRoleMutationVariables, cache, _info) => { + cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); + }, + setOrgMemberRole: (result, args: AddOrgMemberMutationVariables, cache, _info) => { + cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); + }, leaveProject: (result, args: LeaveProjectMutationVariables, cache, _info) => { cache.invalidate({__typename: 'Project', id: args.input.projectId}); }, diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 24ed816d7..5a1a9dec1 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -263,6 +263,21 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "user_needs_to_relogin": "Added members will need to log out and back in again before they see the new organization.", "invalid_email_address": "Invalid email address: {email}", }, + "bulk_add_members": { + "add_button": "Bulk Add Members", + "explanation": "Adds all the entered logins and emails to this organization. Unlike the bulk-add feature for projects, new accounts will NOT be automatically created.", + "modal_title": "Bulk Add Members", + "submit_button": "Add Members", + "finish_button": "Close", + "usernames": "Logins or emails (one per line)", + "usernames_description": "This should be the **email or Send/Receive login** for existing accounts", + "invalid_username": "Invalid login/username: {username}. Only letters, numbers, and underscore (_) characters are allowed.", + "empty_user_field": "Please enter email addresses and/or logins", + "members_added": "{addedCount} new {addedCount, plural, one {member was} other {members were}} added to the organization.", + "already_members": "{count, plural, one {# user was} other {# users were}} already in the organization.", + "accounts_not_found": "{notFoundCount} {notFoundCount, plural, one {user was} other {users were}} not found.", + "invalid_email_address": "Invalid email address: {email}.", + }, "change_role_modal": { "title": "Choose role for {name}", "button_label": "Change Role" diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte index 3de1d91f7..26624b8b7 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte @@ -21,6 +21,7 @@ import OrgMemberTable from './OrgMemberTable.svelte'; import ProjectTable from '$lib/components/Projects/ProjectTable.svelte'; import type { UUID } from 'crypto'; + import BulkAddOrgMembers from './BulkAddOrgMembers.svelte'; export let data: PageData; $: user = data.user; @@ -102,6 +103,7 @@