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
46 changes: 46 additions & 0 deletions backend/LexBoxApi/GraphQL/OrgMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,52 @@ 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>]
[AdminRequired]
rmunn marked this conversation as resolved.
Show resolved Hide resolved
[UseMutationConvention]
public async Task<BulkAddOrgMembersResult> BulkAddOrgMembers(
BulkAddOrgMembersInput input,
LexBoxDbContext dbContext)
{
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
var orgExists = await dbContext.Orgs.AnyAsync(p => p.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
25 changes: 25 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! @authorize(policy: "AdminRequiredPolicy")
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 @@ -462,6 +479,8 @@ union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVeri

union AddProjectToOrgError = DbError | NotFoundError

union BulkAddOrgMembersError = NotFoundError | DbError

union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError

union ChangeOrgMemberRoleError = DbError | NotFoundError
Expand Down Expand Up @@ -518,6 +537,12 @@ input BooleanOperationFilterInput {
neq: Boolean
}

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

input BulkAddProjectMembersInput {
projectId: UUID
usernames: [String!]!
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 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
33 changes: 33 additions & 0 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 @@ -158,6 +159,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script lang="ts">
import Button from '$lib/forms/Button.svelte';
import { DialogResponse, FormModal, type FormSubmitReturn } from '$lib/components/modals';
import { TextArea, isEmail } from '$lib/forms';
import { OrgRole, type BulkAddOrgMembersResult } from '$lib/gql/types';
import t from '$lib/i18n';
import { z } from 'zod';
import { _bulkAddOrgMembers } from './+page';
import { AdminContent } from '$lib/layout';
import Icon from '$lib/icons/Icon.svelte';
import BadgeList from '$lib/components/Badges/BadgeList.svelte';
import { distinct } from '$lib/util/array';
import { SupHelp, helpLinks } from '$lib/components/help';
import { usernameRe } from '$lib/user';
import OrgMemberBadge from '$lib/components/Badges/OrgMemberBadge.svelte';
import type { UUID } from 'crypto';

enum BulkAddSteps {
Add,
Results,
}

let currentStep = BulkAddSteps.Add;

export let orgId: string;
const schema = z.object({
usernamesText: z.string().trim().min(1, $t('org_page.bulk_add_members.empty_user_field')),
});

let formModal: FormModal<typeof schema>;
$: form = formModal?.form();

let addedMembers: BulkAddOrgMembersResult['addedMembers'] = [];
let notFoundMembers: BulkAddOrgMembersResult['notFoundMembers'] = [];
let existingMembers: BulkAddOrgMembersResult['existingMembers'] = [];

function validateBulkAddInput(usernames: string[]): FormSubmitReturn<typeof schema> {
if (usernames.length === 0) return { usernamesText: [$t('org_page.bulk_add_members.empty_user_field')] };

for (const username of usernames) {
if (username.includes('@')) {
if (!isEmail(username)) return { usernamesText: [$t('org_page.bulk_add_members.invalid_email_address', { email: username })] };
} else if (!usernameRe.test(username)) {
return { usernamesText: [$t('org_page.bulk_add_members.invalid_username', { username })] };
}
}
}

async function openModal(): Promise<void> {
currentStep = BulkAddSteps.Add;
console.log('Opening modal');
const { response } = await formModal.open(undefined, async (state) => {
console.log('Submit button clicked');
const usernames = state.usernamesText.currentValue
.split('\n')
// Remove whitespace
.map(s => s.trim())
// Remove empty lines before validating, otherwise final newline would count as invalid because empty string
.filter(s => s)
.filter(distinct);

console.log('Usernames:', usernames);

const bulkErrors = validateBulkAddInput(usernames);
if (bulkErrors) return bulkErrors;

const { error, data } = await _bulkAddOrgMembers(
orgId as UUID,
usernames,
OrgRole.User,
);

console.log('Error:', error);
console.log('Data:', data);

addedMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.addedMembers ?? [];
notFoundMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.notFoundMembers ?? [];
existingMembers = data?.bulkAddOrgMembers.bulkAddOrgMembersResult?.existingMembers ?? [];
return error?.message;
}, { keepOpenOnSubmit: true });

if (response === DialogResponse.Submit) {
currentStep = BulkAddSteps.Results;
}
}
</script>

<AdminContent>
rmunn marked this conversation as resolved.
Show resolved Hide resolved
<Button variant="btn-success" on:click={openModal}>
{$t('org_page.bulk_add_members.add_button')}
<span class="i-mdi-account-multiple-plus-outline text-2xl" />
</Button>

<FormModal bind:this={formModal} {schema} let:errors>
<span slot="title">
{$t('org_page.bulk_add_members.modal_title')}
<SupHelp helpLink={helpLinks.bulkAddCreate} />
</span>
{#if currentStep == BulkAddSteps.Add}
<p class="mb-2">{$t('org_page.bulk_add_members.explanation')}</p>
<div class="contents usernames">
<TextArea
id="usernamesText"
label={$t('org_page.bulk_add_members.usernames')}
description={$t('org_page.bulk_add_members.usernames_description')}
bind:value={$form.usernamesText}
error={errors.usernamesText}
/>
</div>
{:else if currentStep == BulkAddSteps.Results}
<p class="flex gap-1 items-center mb-4">
<Icon icon="i-mdi-plus" color="text-success" />
{$t('org_page.bulk_add_members.members_added', {addedCount: addedMembers.length})}
</p>
<div class="mb-4 ml-8">
{#if addedMembers.length > 0}
<div class="mt-2">
<BadgeList>
{#each addedMembers as user}
<OrgMemberBadge member={{ name: user.username, role: user.role }} />
{/each}
</BadgeList>
</div>
{/if}
</div>
<div class="mb-4">
<p class="flex gap-1 items-center">
<Icon icon="i-mdi-account-off" color="text-info" />
{$t('org_page.bulk_add_members.accounts_not_found', {notFoundCount: notFoundMembers.length})}
</p>
{#if notFoundMembers.length > 0}
<div class="mt-2">
<BadgeList>
{#each notFoundMembers as user}
<OrgMemberBadge member={{ name: user.username, role: user.role }} />
{/each}
</BadgeList>
</div>
{/if}
</div>
{#if existingMembers.length > 0}
<p class="flex gap-1 items-center">
<Icon icon="i-mdi-account-outline" color="text-info" />
{$t('org_page.bulk_add_members.already_members', {count: existingMembers.length})}
</p>
<div class="mt-2">
<BadgeList>
{#each existingMembers as user}
<OrgMemberBadge member={{ name: user.username, role: user.role }} />
{/each}
</BadgeList>
</div>
{/if}
{:else}
<p>Internal error: unknown step {currentStep}</p>
{/if}
<span slot="submitText">{$t('org_page.bulk_add_members.submit_button')}</span>
<span slot="closeText">{$t('org_page.bulk_add_members.finish_button')}</span>
</FormModal>
</AdminContent>

<style lang="postcss">
.usernames :global(.description) {
@apply text-success;
}
</style>
Loading