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';