Skip to content

Commit

Permalink
Allow org admins to create guest users (#1373)
Browse files Browse the repository at this point in the history
* Backend changes so org admins can invite guest users

* Frontend changes so org admins can invite guest users

Org admins now have a "Create User" button on the org page, hidden
behind a dropdown by default to avoid confusion, which allows them to
create users with or without an email address. Users created without an
email address will be guest users; either way, the users will
automatically be added to the organization.

---------

Co-authored-by: Tim Haasdyk <[email protected]>
  • Loading branch information
rmunn and myieye authored Jan 17, 2025
1 parent c0dc0bf commit 30c8d5a
Show file tree
Hide file tree
Showing 11 changed files with 98 additions and 48 deletions.
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Controllers/LoginController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public async Task<ActionResult<LexAuthUser>> VerifyEmail(

user.Email = loggedInContext.User.Email;
user.EmailVerified = true;
// Guest ussers are promoted to "regular" users once they verify an email address
// Guest users are promoted to "regular" users once they verify an email address
user.CreatedById = null;
user.UpdateUpdatedDate();
await lexBoxDbContext.SaveChangesAsync();
Expand Down
24 changes: 7 additions & 17 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,16 @@ public record BulkAddProjectMembersResult(List<UserProjectRole> AddedMembers, Li
[Error<NotFoundException>]
[Error<InvalidEmailException>]
[Error<DbError>]
[AdminRequired]
[UseMutationConvention]
public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
IPermissionService permissionService,
LoggedInContext loggedInContext,
BulkAddProjectMembersInput input,
LexBoxDbContext dbContext)
{
if (input.ProjectId.HasValue)
{
var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value);
if (!projectExists) throw new NotFoundException("Project not found", "project");
}
await permissionService.AssertCanCreateGuestUserInProject(input.ProjectId);
var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId);
if (!projectExists) throw new NotFoundException("Project not found", "project");
List<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> ExistingMembers = [];
Expand Down Expand Up @@ -176,13 +174,10 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
CanCreateProjects = false
};
CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role));
if (input.ProjectId.HasValue)
{
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
dbContext.Add(user);
}
else if (input.ProjectId.HasValue)
else
{
var userProject = user.Projects.FirstOrDefault(p => p.ProjectId == input.ProjectId);
if (userProject is not null)
Expand All @@ -193,14 +188,9 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
{
AddedMembers.Add(new UserProjectRole(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.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
}
}
else
{
// No project ID specified, user already exists. This is probably part of bulk-adding through the admin dashboard or org page.
ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown));
}
}
await dbContext.SaveChangesAsync();
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
Expand Down
10 changes: 8 additions & 2 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ public record CreateGuestUserByAdminInput(
string? Username,
string Locale,
string PasswordHash,
int PasswordStrength);
int PasswordStrength,
Guid? OrgId);

[Error<NotFoundException>]
[Error<DbError>]
Expand Down Expand Up @@ -96,15 +97,16 @@ IEmailService emailService
[Error<DbError>]
[Error<UniqueValueException>]
[Error<RequiredException>]
[AdminRequired]
public async Task<LexAuthUser> CreateGuestUserByAdmin(
IPermissionService permissionService,
LoggedInContext loggedInContext,
CreateGuestUserByAdminInput input,
LexBoxDbContext dbContext,
IEmailService emailService
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");
permissionService.AssertCanCreateGuestUserInOrg(input.OrgId);

var hasExistingUser = input.Email is null && input.Username is null
? throw new RequiredException("Guest users must have either an email or a username")
Expand Down Expand Up @@ -132,6 +134,10 @@ IEmailService emailService
CanCreateProjects = false
};
createGuestUserActivity?.AddTag("app.user.id", userEntity.Id);
if (input.OrgId is not null)
{
userEntity.Organizations.Add(new OrgMember() { OrgId = input.OrgId.Value, Role = OrgRole.User });
}
dbContext.Users.Add(userEntity);
await dbContext.SaveChangesAsync();
if (!string.IsNullOrEmpty(input.Email))
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ namespace LexBoxApi.Models.Project;

public record AddProjectMemberInput(Guid ProjectId, string? UsernameOrEmail, Guid? UserId, ProjectRole Role, bool canInvite);

public record BulkAddProjectMembersInput(Guid? ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);
public record BulkAddProjectMembersInput(Guid ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);

public record ChangeProjectMemberRoleInput(Guid ProjectId, Guid UserId, ProjectRole Role);
26 changes: 26 additions & 0 deletions backend/LexBoxApi/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ public async ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid use
throw new UnauthorizedAccessException("Not allowed to change own project role.");
}

public async ValueTask<bool> CanCreateGuestUserInProject(Guid projectId)
{
if (User is null) return false;
if (User.Role == UserRole.admin) return true;
return await ManagesOrgThatOwnsProject(projectId);
}

public async ValueTask AssertCanCreateGuestUserInProject(Guid projectId)
{
if (!await CanCreateGuestUserInProject(projectId)) throw new UnauthorizedAccessException();
}

public bool CanCreateGuestUserInOrg(Guid? orgId)
{
if (User is null) return false;
if (User.Role == UserRole.admin) return true;
// Site admins can create guest users even with no org, anyone else (like org admins) must specify an org ID
if (orgId is null) return false;
return CanEditOrg(orgId.Value);
}

public void AssertCanCreateGuestUserInOrg(Guid? orgId)
{
if (!CanCreateGuestUserInOrg(orgId)) throw new UnauthorizedAccessException();
}

public async ValueTask<bool> CanAskToJoinProject(Guid projectId)
{
if (User is null) return false;
Expand Down
4 changes: 4 additions & 0 deletions backend/LexCore/ServiceInterfaces/IPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public interface IPermissionService
ValueTask AssertCanManageProject(Guid projectId);
ValueTask AssertCanManageProject(string projectCode);
ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid userId);
ValueTask<bool> CanCreateGuestUserInProject(Guid projectId);
ValueTask AssertCanCreateGuestUserInProject(Guid projectId);
bool CanCreateGuestUserInOrg(Guid? orgId);
void AssertCanCreateGuestUserInOrg(Guid? orgId);
ValueTask<bool> CanAskToJoinProject(Guid projectId);
ValueTask<bool> CanAskToJoinProject(string projectCode);
ValueTask AssertCanAskToJoinProject(Guid projectId);
Expand Down
18 changes: 4 additions & 14 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ type Mutation {
changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! @cost(weight: "10")
createProject(input: CreateProjectInput!): CreateProjectPayload! @authorize(policy: "VerifiedEmailRequiredPolicy") @cost(weight: "10")
addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! @cost(weight: "10")
bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @cost(weight: "10")
changeProjectMemberRole(input: ChangeProjectMemberRoleInput!): ChangeProjectMemberRolePayload! @cost(weight: "10")
askToJoinProject(input: AskToJoinProjectInput!): AskToJoinProjectPayload! @cost(weight: "10")
changeProjectName(input: ChangeProjectNameInput!): ChangeProjectNamePayload! @cost(weight: "10")
Expand All @@ -273,7 +273,7 @@ type Mutation {
changeUserAccountBySelf(input: ChangeUserAccountBySelfInput!): ChangeUserAccountBySelfPayload! @cost(weight: "10")
changeUserAccountByAdmin(input: ChangeUserAccountByAdminInput!): ChangeUserAccountByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
sendNewVerificationEmailByAdmin(input: SendNewVerificationEmailByAdminInput!): SendNewVerificationEmailByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
createGuestUserByAdmin(input: CreateGuestUserByAdminInput!): CreateGuestUserByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
createGuestUserByAdmin(input: CreateGuestUserByAdminInput!): CreateGuestUserByAdminPayload! @cost(weight: "10")
deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput!): DeleteUserByAdminOrSelfPayload! @cost(weight: "10")
setUserLocked(input: SetUserLockedInput!): SetUserLockedPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
}
Expand Down Expand Up @@ -441,10 +441,8 @@ type Query {
projectsInMyOrg(input: ProjectsInMyOrgInput! where: ProjectFilterInput @cost(weight: "10") orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @cost(weight: "10")
projectById(projectId: UUID!): Project @cost(weight: "10")
projectByCode(code: String!): Project @cost(weight: "10")
draftProjectByCode(code: String!): DraftProject @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10")
orgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10")
myOrgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10")
usersInMyOrg(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersInMyOrgCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
usersICanSee(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersICanSeeCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
orgById(orgId: UUID!): OrgById @cost(weight: "10")
users(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10")
Expand Down Expand Up @@ -575,15 +573,6 @@ type UsersICanSeeCollectionSegment {
totalCount: Int! @cost(weight: "10")
}

"A segment of a collection."
type UsersInMyOrgCollectionSegment {
"Information to aid in pagination."
pageInfo: CollectionSegmentInfo!
"A flattened list of the items."
items: [User!]
totalCount: Int! @cost(weight: "10")
}

union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError

union AddProjectToOrgError = DbError | NotFoundError
Expand Down Expand Up @@ -684,7 +673,7 @@ input BulkAddOrgMembersInput {
}

input BulkAddProjectMembersInput {
projectId: UUID
projectId: UUID!
usernames: [String!]!
role: ProjectRole!
passwordHash: String!
Expand Down Expand Up @@ -738,6 +727,7 @@ input CreateGuestUserByAdminInput {
locale: String!
passwordHash: String!
passwordStrength: Int!
orgId: UUID
}

input CreateOrganizationInput {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lib/gql/gql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
type MutationBulkAddProjectMembersArgs,
type MutationChangeOrgMemberRoleArgs,
type MutationChangeUserAccountBySelfArgs,
type MutationCreateGuestUserByAdminArgs,
type MutationCreateOrganizationArgs,
type MutationCreateProjectArgs,
type MutationDeleteDraftProjectArgs,
Expand Down Expand Up @@ -95,8 +96,11 @@ function createGqlClient(_gqlEndpoint?: string): Client {
cache.invalidate({__typename: 'User', id: args.input.userId});
},
bulkAddProjectMembers: (result, args: MutationBulkAddProjectMembersArgs, cache, _info) => {
if (args.input.projectId) {
cache.invalidate({__typename: 'Project', id: args.input.projectId});
cache.invalidate({__typename: 'Project', id: args.input.projectId});
},
createGuestUserByAdmin: (result, args: MutationCreateGuestUserByAdminArgs, cache, _info) => {
if (args.input.orgId) {
cache.invalidate({__typename: 'OrgById', id: args.input.orgId});
}
},
createOrganization: (result: CreateOrgMutation, args: MutationCreateOrganizationArgs, cache, _info) => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,14 @@ export function register(password: string, passwordStrength: number, name: strin
export function acceptInvitation(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise<RegisterResponse> {
return createUser('/api/User/acceptInvitation', password, passwordStrength, name, email, locale, turnstileToken);
}
export async function createGuestUserByAdmin(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string): Promise<RegisterResponse> {
export async function createGuestUserByAdmin(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string, orgId?: string): Promise<RegisterResponse> {
const passwordHash = await hash(password);
const gqlInput: CreateGuestUserByAdminInput = {
passwordHash,
passwordStrength,
name,
locale,
orgId,
};
if (email.includes('@')) {
gqlInput.email = email;
Expand Down
41 changes: 38 additions & 3 deletions frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import BulkAddOrgMembers from './BulkAddOrgMembers.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import AddMyProjectsToOrgModal from './AddMyProjectsToOrgModal.svelte';
import CreateUserModal from '$lib/components/Users/CreateUserModal.svelte';
import {createGuestUserByAdmin, type LexAuthUser} from '$lib/user';
import {Duration} from '$lib/util/time';
import IconButton from '$lib/components/IconButton.svelte';
export let data: PageData;
$: user = data.user;
Expand Down Expand Up @@ -63,6 +67,8 @@
await addOrgMemberModal.openModal();
}
let bulkAddMembersModal: BulkAddOrgMembers;
let changeMemberRoleModal: ChangeOrgMemberRoleModal;
async function openChangeMemberRoleModal(member: OrgUser): Promise<void> {
await changeMemberRoleModal.open({
Expand Down Expand Up @@ -113,6 +119,15 @@
await goto('/');
}
}
function createGuestUser(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string): ReturnType<typeof createGuestUserByAdmin> {
return createGuestUserByAdmin(password, passwordStrength, name, email, locale, _turnstileToken, org.id);
}
let createUserModal: CreateUserModal;
function onUserCreated(user: LexAuthUser): void {
notifySuccess($t('admin_dashboard.notifications.user_created', { name: user.name }), Duration.Long);
}
</script>

<PageBreadcrumb href="/org/list">{$t('org.table.title')}</PageBreadcrumb>
Expand All @@ -123,13 +138,33 @@
<AddMyProjectsToOrgModal {user} {org} />
{/if}
{#if canManage}
<Button variant="btn-success"
<div class="join gap-x-0.5">
<Button variant="btn-success" class="join-item"
on:click={openAddOrgMemberModal}>
{$t('org_page.add_user.add_button')}
<span class="i-mdi-account-plus-outline text-2xl" />
</Button>
<AddOrgMemberModal bind:this={addOrgMemberModal} {org} />
<BulkAddOrgMembers orgId={org.id} />
<Dropdown>
<IconButton icon="i-mdi-menu-down" variant="btn-success" join outline={false} />
<ul slot="content" class="menu">
<li>
<button class="whitespace-nowrap" on:click={() => bulkAddMembersModal.open()}>
{$t('org_page.bulk_add_members.add_button')}
<Icon icon="i-mdi-account-multiple-plus-outline" />
</button>
</li>
<li>
<button class="whitespace-nowrap" on:click={() => createUserModal.open()}>
{$t('admin_dashboard.create_user_modal.create_user')}
<Icon icon="i-mdi-plus" />
</button>
</li>
</ul>
</Dropdown>
</div>
<CreateUserModal handleSubmit={createGuestUser} on:submitted={(e) => onUserCreated(e.detail)} bind:this={createUserModal}/>
<AddOrgMemberModal bind:this={addOrgMemberModal} {org} />
<BulkAddOrgMembers bind:this={bulkAddMembersModal} orgId={org.id} />
{/if}
</svelte:fragment>
<div slot="title" class="max-w-full flex items-baseline flex-wrap">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<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';
Expand Down Expand Up @@ -46,7 +45,7 @@
}
}
async function openModal(): Promise<void> {
export async function open(): Promise<void> {
currentStep = BulkAddSteps.Add;
const { response } = await formModal.open(undefined, async (state) => {
const usernames = state.usernamesText.currentValue
Expand Down Expand Up @@ -79,11 +78,6 @@
}
</script>

<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')}
Expand Down

0 comments on commit 30c8d5a

Please sign in to comment.