Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bulk-add users to existing org #887

Merged
merged 12 commits into from
Jul 23, 2024
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);
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved

[Error<NotFoundException>]
[Error<DbError>]
[Error<UnauthorizedAccessException>]
[UseMutationConvention]
public async Task<BulkAddOrgMembersResult> BulkAddOrgMembers(
BulkAddOrgMembersInput input,
IPermissionService permissionService,
LexBoxDbContext dbContext)
{
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
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",
rmunn marked this conversation as resolved.
Show resolved Hide resolved
"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) {
rmunn marked this conversation as resolved.
Show resolved Hide resolved
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
Loading