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

Allow org admins to create guest users #1373

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 5 additions & 6 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,17 @@ 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);
if (input.ProjectId == null) throw new NotFoundException("Project not found", "project");
var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value);
if (!projectExists) throw new NotFoundException("Project not found", "project");
List<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> 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
28 changes: 28 additions & 0 deletions backend/LexBoxApi/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,34 @@ 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;
// Site admins can create guest users even with no project, anyone else (like org admins) must specify a project ID
if (projectId is null) return false;
return await ManagesOrgThatOwnsProject(projectId.Value);
}

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
16 changes: 3 additions & 13 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 @@ -738,6 +727,7 @@ input CreateGuestUserByAdminInput {
locale: String!
passwordHash: String!
passwordStrength: Int!
orgId: UUID
}

input CreateOrganizationInput {
Expand Down
6 changes: 6 additions & 0 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 @@ -99,6 +100,11 @@ function createGqlClient(_gqlEndpoint?: string): Client {
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) => {
cache.invalidate('Query', 'myOrgs');
},
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
Loading