From e3182a145d7be8e1191ad4a649e52ad5f0ebd8ce Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:02:14 +0100 Subject: [PATCH] Implement updateRole (#10009) In this PR, we are implementing the updateRole endpoint with the following rules 1. A user can only update a member's role if they have the permission (= the admin role) 2. Admin role can't be unassigned if there are no other admin in the workspace 3. (For now) as members can only have one role for now, when they are assigned a new role, they are first unassigned the other role (if any) 4. (For now) removing a member's admin role = leaving the member with no role = calling updateRole with a null roleId --- .../user-workspace/user-workspace.module.ts | 2 + .../user-workspace/user-workspace.service.ts | 51 +++++++++ .../user/dtos/workspace-member.dto.ts | 7 ++ .../engine/core-modules/user/user.module.ts | 9 +- .../engine/core-modules/user/user.resolver.ts | 100 ++++++++++++++--- .../permissions/permissions.exception.ts | 3 + .../permissions/permissions.module.ts | 2 +- .../permissions/permissions.service.ts | 10 +- ...rmissions-graphql-api-exception-handler.ts | 1 + .../metadata-modules/role/role.module.ts | 4 +- .../metadata-modules/role/role.resolver.ts | 66 ++++++++++- .../user-role.module.ts} | 2 +- .../user-role.service.ts} | 106 ++++++++++++------ .../workspace-manager.module.ts | 2 +- .../workspace-manager.service.ts | 2 +- 15 files changed, 303 insertions(+), 64 deletions(-) rename packages/twenty-server/src/engine/metadata-modules/{userRole/userRole.module.ts => user-role/user-role.module.ts} (96%) rename packages/twenty-server/src/engine/metadata-modules/{userRole/userRole.service.ts => user-role/user-role.service.ts} (65%) diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index 56edb5e7f..0a854a26a 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -13,6 +13,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; @Module({ @@ -28,6 +29,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works DataSourceModule, WorkspaceDataSourceModule, WorkspaceInvitationModule, + TwentyORMModule, ], services: [UserWorkspaceService], }), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 2193e0296..4e9d18f5f 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -2,6 +2,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; +import { isDefined } from 'twenty-shared'; import { Repository } from 'typeorm'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; @@ -19,7 +20,9 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { assert } from 'src/utils/assert'; export class UserWorkspaceService extends TypeOrmQueryService { @@ -34,6 +37,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { private readonly typeORMService: TypeORMService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) { super(userWorkspaceRepository); } @@ -196,4 +200,51 @@ export class UserWorkspaceService extends TypeOrmQueryService { ), })); } + + async getUserWorkspaceForUserOrThrow({ + userId, + workspaceId, + }: { + userId: string; + workspaceId: string; + }): Promise { + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { + userId, + workspaceId, + }, + }); + + if (!isDefined(userWorkspace)) { + throw new Error('User workspace not found'); + } + + return userWorkspace; + } + + async getWorkspaceMemberOrThrow({ + workspaceMemberId, + workspaceId, + }: { + workspaceMemberId: string; + workspaceId: string; + }): Promise { + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workspaceMember', + ); + + const workspaceMember = await workspaceMemberRepository.findOne({ + where: { + id: workspaceMemberId, + }, + }); + + if (!isDefined(workspaceMember)) { + throw new Error('Workspace member not found'); + } + + return workspaceMember; + } } diff --git a/packages/twenty-server/src/engine/core-modules/user/dtos/workspace-member.dto.ts b/packages/twenty-server/src/engine/core-modules/user/dtos/workspace-member.dto.ts index a1c1e6393..fdcccf582 100644 --- a/packages/twenty-server/src/engine/core-modules/user/dtos/workspace-member.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/user/dtos/workspace-member.dto.ts @@ -3,6 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { WorkspaceMemberDateFormatEnum, WorkspaceMemberTimeFormatEnum, @@ -42,4 +43,10 @@ export class WorkspaceMember { @Field(() => WorkspaceMemberTimeFormatEnum, { nullable: true }) timeFormat: WorkspaceMemberTimeFormatEnum; + + @Field(() => [RoleDTO], { nullable: true }) + roles?: RoleDTO[]; + + @Field(() => String) + userWorkspaceId: string; } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 720cb92a2..5bde7e278 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -8,17 +8,20 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserResolver } from 'src/engine/core-modules/user/user.resolver'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -39,10 +42,12 @@ import { UserService } from './services/user.service'; FileUploadModule, WorkspaceModule, OnboardingModule, - TypeOrmModule.forFeature([KeyValuePair], 'core'), + TypeOrmModule.forFeature([KeyValuePair, UserWorkspace], 'core'), UserVarsModule, AnalyticsModule, DomainManagerModule, + UserRoleModule, + FeatureFlagModule, ], exports: [UserService], providers: [UserService, UserResolver, TypeORMService], diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 2d5bb9d00..43468b7b7 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -13,14 +13,22 @@ import crypto from 'crypto'; import { GraphQLJSONObject } from 'graphql-type-json'; import { FileUpload, GraphQLUpload } from 'graphql-upload'; -import { Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared'; +import { In, Repository } from 'typeorm'; import { SupportDriver } from 'src/engine/core-modules/environment/interfaces/support.interface'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { AnalyticsService } from 'src/engine/core-modules/analytics/analytics.service'; import { AnalyticsTinybirdJwtMap } from 'src/engine/core-modules/analytics/entities/analytics-tinybird-jwts.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; @@ -28,25 +36,22 @@ import { OnboardingService, OnboardingStepKeys, } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type'; -import { - AuthException, - AuthExceptionCode, -} from 'src/engine/core-modules/auth/auth.exception'; -import { userValidator } from 'src/engine/core-modules/user/user.validate'; -import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; const getHMACKey = (email?: string, key?: string | null) => { if (!email || !key) return null; @@ -70,6 +75,10 @@ export class UserResolver { private readonly fileService: FileService, private readonly analyticsService: AnalyticsService, private readonly domainManagerService: DomainManagerService, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, + private readonly userRoleService: UserRoleService, + private readonly featureFlagService: FeatureFlagService, ) {} @Query(() => User) @@ -159,22 +168,81 @@ export class UserResolver { @Parent() user: User, @AuthWorkspace() workspace: Workspace, ): Promise { - const workspaceMembers = + const workspaceMemberEntities = await this.userService.loadWorkspaceMembers(workspace); - for (const workspaceMember of workspaceMembers) { - if (workspaceMember.avatarUrl) { + const workspaceMembers: WorkspaceMember[] = []; + + const userWorkspaces = await this.userWorkspaceRepository.find({ + where: { + userId: In(workspaceMemberEntities.map((entity) => entity.userId)), + workspaceId: workspace.id, + }, + }); + + const userWorkspacesByUserId = new Map( + userWorkspaces.map((userWorkspace) => [ + userWorkspace.userId, + userWorkspace, + ]), + ); + + for (const workspaceMemberEntity of workspaceMemberEntities) { + if (workspaceMemberEntity.avatarUrl) { const avatarUrlToken = await this.fileService.encodeFileToken({ - workspaceMemberId: workspaceMember.id, + workspaceMemberId: workspaceMemberEntity.id, workspaceId: workspace.id, }); - workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; + workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`; } + + const userWorkspace = userWorkspacesByUserId.get( + workspaceMemberEntity.userId, + ); + + if (!userWorkspace) { + throw new Error('User workspace not found'); + } + + const permissionsEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsPermissionsEnabled, + workspace.id, + ); + + const workspaceMember: WorkspaceMember = { + ...workspaceMemberEntity, + userWorkspaceId: userWorkspace.id, + } as WorkspaceMember; + + if (permissionsEnabled === true) { + const roles = await this.userRoleService + .getRolesForUserWorkspace(userWorkspace.id) + .then(([roleEntity]) => { + if (!isDefined(roleEntity)) { + return []; + } + + return [ + { + id: roleEntity.id, + label: roleEntity.label, + canUpdateAllSettings: roleEntity.canUpdateAllSettings, + description: roleEntity.description, + isEditable: roleEntity.isEditable, + userWorkspaceRoles: roleEntity.userWorkspaceRoles, + }, + ]; + }); + + workspaceMember.roles = roles; + } + + workspaceMembers.push(workspaceMember); } // TODO: Fix typing disrepency between Entity and DTO - return workspaceMembers as WorkspaceMember[]; + return workspaceMembers; } @ResolveField(() => String, { diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index c56b1d503..5467f9d89 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -14,4 +14,7 @@ export enum PermissionsExceptionCode { TOO_MANY_ADMIN_CANDIDATES = 'TOO_MANY_ADMIN_CANDIDATES', USER_WORKSPACE_ALREADY_HAS_ROLE = 'USER_WORKSPACE_ALREADY_HAS_ROLE', PERMISSION_DENIED = 'PERMISSION_DENIED', + WORKSPACE_MEMBER_NOT_FOUND = 'WORKSPACE_MEMBER_NOT_FOUND', + ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', + CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts index 079541b3c..b59549e85 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts @@ -7,7 +7,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; -import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index 504f8f21f..053933dda 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -7,7 +7,7 @@ import { PermissionsException, PermissionsExceptionCode, } from 'src/engine/metadata-modules/permissions/permissions.exception'; -import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @Injectable() export class PermissionsService { @@ -21,8 +21,8 @@ export class PermissionsService { }: { userWorkspaceId: string; }): Promise> { - const roleOfUserWorkspace = - await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId); + const [roleOfUserWorkspace] = + await this.userRoleService.getRolesForUserWorkspace(userWorkspaceId); let hasPermissionOnSettingFeature = false; @@ -46,8 +46,8 @@ export class PermissionsService { userWorkspaceId: string; setting: SettingsFeatures; }): Promise { - const userWorkspaceRole = - await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId); + const [userWorkspaceRole] = + await this.userRoleService.getRolesForUserWorkspace(userWorkspaceId); if (userWorkspaceRole?.canUpdateAllSettings === true) { return; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler.ts index 4569fde35..4da377e16 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler.ts @@ -11,6 +11,7 @@ export const permissionsGraphqlApiExceptionHandler = (error: Error) => { if (error instanceof PermissionsException) { switch (error.code) { case PermissionsExceptionCode.PERMISSION_DENIED: + case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: throw new ForbiddenError(error.message); default: throw new InternalServerError(error.message); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts index 3e618f89f..5aa3791e4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; -import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.mo TypeOrmModule.forFeature([UserWorkspace], 'core'), UserRoleModule, PermissionsModule, + UserWorkspaceModule, ], providers: [RoleService, RoleResolver], exports: [RoleService], diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 9f7db391a..df58b3349 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -1,7 +1,15 @@ -import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; -import { SettingsFeatures } from 'twenty-shared'; +import { isDefined, SettingsFeatures } from 'twenty-shared'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; @@ -10,7 +18,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { permissionsGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; -import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @Resolver(() => RoleDTO) @@ -19,6 +27,7 @@ export class RoleResolver { private readonly userRoleService: UserRoleService, private readonly permissionsService: PermissionsService, private readonly roleService: RoleService, + private readonly userWorkspaceService: UserWorkspaceService, ) {} @Query(() => [RoleDTO]) @@ -52,6 +61,57 @@ export class RoleResolver { } } + @Mutation(() => WorkspaceMember) + async updateWorkspaceMemberRole( + @AuthUserWorkspaceId() currentUserWorkspaceId: string, + @AuthWorkspace() workspace: Workspace, + @Args('workspaceMemberId') workspaceMemberId: string, + @Args('roleId', { type: () => String, nullable: true }) + roleId: string | null, + ): Promise { + await this.permissionsService.validateUserHasWorkspaceSettingPermissionOrThrow( + { + userWorkspaceId: currentUserWorkspaceId, + setting: SettingsFeatures.ROLES, + }, + ); + + const workspaceMember = + await this.userWorkspaceService.getWorkspaceMemberOrThrow({ + workspaceMemberId, + workspaceId: workspace.id, + }); + + const userWorkspace = + await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({ + userId: workspaceMember.userId, + workspaceId: workspace.id, + }); + + if (!isDefined(roleId)) { + await this.userRoleService.unassignAllRolesFromUserWorkspace({ + userWorkspaceId: userWorkspace.id, + workspaceId: workspace.id, + }); + } else { + await this.userRoleService.assignRoleToUserWorkspace({ + userWorkspaceId: userWorkspace.id, + workspaceId: workspace.id, + roleId, + }); + } + + const roles = await this.userRoleService.getRolesForUserWorkspace( + userWorkspace.id, + ); + + return { + ...workspaceMember, + userWorkspaceId: userWorkspace.id, + roles, + } as WorkspaceMember; + } + @ResolveField('workspaceMembers', () => [WorkspaceMember]) async getWorkspaceMembersAssignedToRole( @Parent() role: RoleDTO, diff --git a/packages/twenty-server/src/engine/metadata-modules/userRole/userRole.module.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts similarity index 96% rename from packages/twenty-server/src/engine/metadata-modules/userRole/userRole.module.ts rename to packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts index e36dd55eb..9c9533226 100644 --- a/packages/twenty-server/src/engine/metadata-modules/userRole/userRole.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; -import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/userRole/userRole.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts similarity index 65% rename from packages/twenty-server/src/engine/metadata-modules/userRole/userRole.service.ts rename to packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index d054c4636..88c06d8a2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/userRole/userRole.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -1,6 +1,5 @@ import { InjectRepository } from '@nestjs/typeorm'; -import isEmpty from 'lodash.isempty'; import { isDefined } from 'twenty-shared'; import { In, Repository } from 'typeorm'; @@ -9,6 +8,7 @@ import { PermissionsException, PermissionsExceptionCode, } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -25,29 +25,6 @@ export class UserRoleService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} - public async unassignRolesFromUserWorkspace({ - userWorkspaceId, - workspaceId, - }: { - userWorkspaceId: string; - workspaceId: string; - }): Promise { - const userWorkspaceRoles = await this.userWorkspaceRoleRepository.find({ - where: { - userWorkspaceId, - workspaceId, - }, - }); - - if (!isEmpty(userWorkspaceRoles)) { - userWorkspaceRoles.forEach(async (userWorkspaceRole) => { - await this.userWorkspaceRoleRepository.delete({ - id: userWorkspaceRole.id, - }); - }); - } - } - public async assignRoleToUserWorkspace({ workspaceId, userWorkspaceId, @@ -70,7 +47,26 @@ export class UserRoleService { ); } - await this.unassignRolesFromUserWorkspace({ + const role = await this.roleRepository.findOne({ + where: { + id: roleId, + }, + }); + + if (!isDefined(role)) { + throw new PermissionsException( + 'Role not found', + PermissionsExceptionCode.ROLE_NOT_FOUND, + ); + } + + const [currentRole] = await this.getRolesForUserWorkspace(userWorkspace.id); + + if (currentRole?.id === roleId) { + return; + } + + await this.unassignAllRolesFromUserWorkspace({ userWorkspaceId: userWorkspace.id, workspaceId, }); @@ -82,13 +78,27 @@ export class UserRoleService { }); } - public async getRoleForUserWorkspace( - userWorkspaceId: string, - ): Promise { - if (!isDefined(userWorkspaceId)) { - return null; - } + public async unassignAllRolesFromUserWorkspace({ + userWorkspaceId, + workspaceId, + }: { + userWorkspaceId: string; + workspaceId: string; + }): Promise { + await this.validatesUserWorkspaceIsNotLastAdminIfUnassigningAdminRoleOrThrow( + userWorkspaceId, + workspaceId, + ); + await this.userWorkspaceRoleRepository.delete({ + userWorkspaceId, + workspaceId, + }); + } + + public async getRolesForUserWorkspace( + userWorkspaceId: string, + ): Promise { const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({ where: { userWorkspaceId, @@ -96,14 +106,23 @@ export class UserRoleService { }); if (!isDefined(userWorkspaceRole)) { - return null; + return []; } - return await this.roleRepository.findOne({ + const role = await this.roleRepository.findOne({ where: { id: userWorkspaceRole.roleId, }, }); + + if (!isDefined(role)) { + throw new PermissionsException( + 'Role not found', + PermissionsExceptionCode.ROLE_NOT_FOUND, + ); + } + + return [role]; } public async getWorkspaceMembersAssignedToRole( @@ -145,4 +164,25 @@ export class UserRoleService { return workspaceMembers; } + + private async validatesUserWorkspaceIsNotLastAdminIfUnassigningAdminRoleOrThrow( + userWorkspaceId: string, + workspaceId: string, + ): Promise { + const roles = await this.getRolesForUserWorkspace(userWorkspaceId); + + const adminRole = roles.find((role: RoleDTO) => role.isEditable === false); + + if (isDefined(adminRole)) { + const workspaceMembersWithAdminRole = + await this.getWorkspaceMembersAssignedToRole(adminRole.id, workspaceId); + + if (workspaceMembersWithAdminRole.length === 1) { + throw new PermissionsException( + `Cannot unassign admin role as there is only one admin in the workspace`, + PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN, + ); + } + } + } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts index 0b2c981ab..951564cd8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts @@ -9,7 +9,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; -import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; import { SeederModule } from 'src/engine/seeder/seeder.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 29801bfba..05175f474 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -16,7 +16,7 @@ import { import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; -import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; import { PETS_DATA_SEEDS } from 'src/engine/seeder/data-seeds/pets-data-seeds'; import { SURVEY_RESULTS_DATA_SEEDS } from 'src/engine/seeder/data-seeds/survey-results-data-seeds';