diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 3d4f6d21b..8c6f76e5a 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -170,7 +170,7 @@ public async Task> 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(); diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 55e2b8ed5..8d4070514 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -134,18 +134,16 @@ public record BulkAddProjectMembersResult(List AddedMembers, Li [Error] [Error] [Error] - [AdminRequired] [UseMutationConvention] public async Task 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 AddedMembers = []; List CreatedMembers = []; List ExistingMembers = []; @@ -176,13 +174,10 @@ public async Task 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) @@ -193,14 +188,9 @@ public async Task 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); diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index 72e92252e..ad9ce2e71 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -34,7 +34,8 @@ public record CreateGuestUserByAdminInput( string? Username, string Locale, string PasswordHash, - int PasswordStrength); + int PasswordStrength, + Guid? OrgId); [Error] [Error] @@ -96,8 +97,8 @@ IEmailService emailService [Error] [Error] [Error] - [AdminRequired] public async Task CreateGuestUserByAdmin( + IPermissionService permissionService, LoggedInContext loggedInContext, CreateGuestUserByAdminInput input, LexBoxDbContext dbContext, @@ -105,6 +106,7 @@ 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") @@ -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)) diff --git a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs index 16a8d56cb..05c339787 100644 --- a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs +++ b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs @@ -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); diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 983461be7..83e7aac19 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -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 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 CanAskToJoinProject(Guid projectId) { if (User is null) return false; diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index f575a4af3..ef1225064 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -20,6 +20,10 @@ public interface IPermissionService ValueTask AssertCanManageProject(Guid projectId); ValueTask AssertCanManageProject(string projectCode); ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid userId); + ValueTask CanCreateGuestUserInProject(Guid projectId); + ValueTask AssertCanCreateGuestUserInProject(Guid projectId); + bool CanCreateGuestUserInOrg(Guid? orgId); + void AssertCanCreateGuestUserInOrg(Guid? orgId); ValueTask CanAskToJoinProject(Guid projectId); ValueTask CanAskToJoinProject(string projectCode); ValueTask AssertCanAskToJoinProject(Guid projectId); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 3ee3ef5e3..c090efba6 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -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") @@ -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") } @@ -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") @@ -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 @@ -684,7 +673,7 @@ input BulkAddOrgMembersInput { } input BulkAddProjectMembersInput { - projectId: UUID + projectId: UUID! usernames: [String!]! role: ProjectRole! passwordHash: String! @@ -738,6 +727,7 @@ input CreateGuestUserByAdminInput { locale: String! passwordHash: String! passwordStrength: Int! + orgId: UUID } input CreateOrganizationInput { diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index caf3f7e05..f2ebe2ac2 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -37,6 +37,7 @@ import { type MutationBulkAddProjectMembersArgs, type MutationChangeOrgMemberRoleArgs, type MutationChangeUserAccountBySelfArgs, + type MutationCreateGuestUserByAdminArgs, type MutationCreateOrganizationArgs, type MutationCreateProjectArgs, type MutationDeleteDraftProjectArgs, @@ -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) => { diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index eb7ae5e8c..cbd14a2a8 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -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 { 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 { +export async function createGuestUserByAdmin(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string, orgId?: string): Promise { const passwordHash = await hash(password); const gqlInput: CreateGuestUserByAdminInput = { passwordHash, passwordStrength, name, locale, + orgId, }; if (email.includes('@')) { gqlInput.email = email; diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte index 9417d1d4e..b6214748f 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/+page.svelte @@ -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; @@ -63,6 +67,8 @@ await addOrgMemberModal.openModal(); } + let bulkAddMembersModal: BulkAddOrgMembers; + let changeMemberRoleModal: ChangeOrgMemberRoleModal; async function openChangeMemberRoleModal(member: OrgUser): Promise { await changeMemberRoleModal.open({ @@ -113,6 +119,15 @@ await goto('/'); } } + + function createGuestUser(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string): ReturnType { + 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); + } {$t('org.table.title')} @@ -123,13 +138,33 @@ {/if} {#if canManage} - - - + + + + + + onUserCreated(e.detail)} bind:this={createUserModal}/> + + {/if}
diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte index b3a03a145..2fb829259 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/BulkAddOrgMembers.svelte @@ -1,5 +1,4 @@ - - {$t('org_page.bulk_add_members.modal_title')}