Skip to content

Commit

Permalink
Bulk-add users to existing org (#887)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
rmunn and hahn-kev authored Jul 23, 2024
1 parent 7ac36b2 commit 6d6e539
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 24 deletions.
48 changes: 48 additions & 0 deletions backend/LexBoxApi/GraphQL/OrgMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrgMemberRole> AddedMembers, List<OrgMemberRole> NotFoundMembers, List<OrgMemberRole> ExistingMembers);

[Error<NotFoundException>]
[Error<DbError>]
[Error<UnauthorizedAccessException>]
[UseMutationConvention]
public async Task<BulkAddOrgMembersResult> 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<Organization>();
List<OrgMemberRole> AddedMembers = [];
List<OrgMemberRole> ExistingMembers = [];
List<OrgMemberRole> 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<NotFoundException>]
[Error<DbError>]
[Error<RequiredException>]
Expand Down
5 changes: 5 additions & 0 deletions backend/LexBoxApi/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions backend/LexCore/ServiceInterfaces/IPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
29 changes: 29 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!]
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -293,6 +305,11 @@ type OrgMemberDtoCreatedBy {
name: String!
}

type OrgMemberRole {
username: String!
role: OrgRole!
}

type OrgProjects {
org: Organization!
project: Project!
Expand Down Expand Up @@ -418,6 +435,10 @@ type SoftDeleteProjectPayload {
errors: [SoftDeleteProjectError!]
}

type UnauthorizedAccessError implements Error {
message: String!
}

type UniqueValueError implements Error {
message: String!
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -518,6 +541,12 @@ input BooleanOperationFilterInput {
neq: Boolean
}

input BulkAddOrgMembersInput {
orgId: UUID!
usernames: [String!]!
role: OrgRole!
}

input BulkAddProjectMembersInput {
projectId: UUID
usernames: [String!]!
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/lib/components/Badges/OrgMemberBadge.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { OrgRole } from '$lib/gql/types';
import FormatUserOrgRole from '../Orgs/FormatUserOrgRole.svelte';
import ActionBadge from './ActionBadge.svelte';
import Badge from './Badge.svelte';
export let member: { name: string; role: OrgRole };
export let canManage = false;
export let type: 'existing' | 'new' = 'existing';
$: actionIcon = (type === 'existing' ? 'i-mdi-dots-vertical' as const : 'i-mdi-close' as const);
$: variant = member.role === OrgRole.Admin ? 'btn-primary' as const : 'btn-secondary' as const;
</script>

<ActionBadge {actionIcon} {variant} disabled={!canManage} on:action>
<span class="pr-3 whitespace-nowrap overflow-ellipsis overflow-x-clip" title={member.name}>
{member.name}
</span>

<!-- justify the name left and the role right -->
<span class="flex-grow" />

<Badge>
<FormatUserOrgRole role={member.role} />
</Badge>
</ActionBadge>
12 changes: 12 additions & 0 deletions frontend/src/lib/gql/gql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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});
},
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,6 +103,7 @@
<span class="i-mdi-account-plus-outline text-2xl" />
</Button>
<AddOrgMemberModal bind:this={addOrgMemberModal} orgId={org.id} />
<BulkAddOrgMembers orgId={org.id} />
{/if}
</svelte:fragment>
<div slot="title" class="max-w-full flex items-baseline flex-wrap">
Expand Down
57 changes: 33 additions & 24 deletions frontend/src/routes/(authenticated)/org/[org_id]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
$OpResult,
AddOrgMemberMutation,
BulkAddOrgMembersMutation,
ChangeOrgMemberRoleMutation,
ChangeOrgNameInput,
ChangeOrgNameMutation,
Expand Down Expand Up @@ -109,14 +110,6 @@ export async function _deleteOrgUser(orgId: string, userId: string): $OpResult<D
changeOrgMemberRole(input: $input) {
organization {
id
members {
id
role
user {
id
name
}
}
}
}
}
Expand All @@ -135,14 +128,6 @@ export async function _addOrgMember(orgId: UUID, emailOrUsername: string, role:
setOrgMemberRole(input: $input) {
organization {
id
members {
id
role
user {
id
name
}
}
}
errors {
__typename
Expand All @@ -158,6 +143,38 @@ export async function _addOrgMember(orgId: UUID, emailOrUsername: string, role:
return result;
}

export async function _bulkAddOrgMembers(orgId: UUID, usernames: string[], role: OrgRole): $OpResult<BulkAddOrgMembersMutation> {
//language=GraphQL
const result = await getClient()
.mutation(
graphql(`
mutation BulkAddOrgMembers($input: BulkAddOrgMembersInput!) {
bulkAddOrgMembers(input: $input) {
bulkAddOrgMembersResult {
addedMembers {
username
role
}
existingMembers {
username
role
}
notFoundMembers {
username
role
}
}
errors {
__typename
}
}
}
`),
{ input: { orgId, usernames, role } }
);
return result;
}

export async function _orgMemberById(orgId: UUID, userId: UUID): Promise<OrgMemberDto> {
//language=GraphQL
const result = await getClient()
Expand Down Expand Up @@ -201,14 +218,6 @@ export async function _changeOrgMemberRole(orgId: string, userId: string, role:
changeOrgMemberRole(input: $input) {
organization {
id
members {
id
role
user {
id
name
}
}
}
errors {
__typename
Expand Down
Loading

0 comments on commit 6d6e539

Please sign in to comment.