From 20a2c3836ed0ce7d8be6326cf4849b20f67c430a Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:58:32 +0500 Subject: [PATCH] feat: introduce role selector when inviting members to a workspace (#18085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds an explicit role selector to the "Invite by email" flow, requires a role choice before sending, and stores the selected role with each invitation. The backend now accepts and persists `roleId` on invitations and applies it when the invite is accepted, while keeping it optional to avoid breaking existing clients and legacy invites. --- ### Frontend - **Settings → Members → Invite by email** - New **Role** dropdown (same `Select` pattern as member/API key role selectors) between the email input and Invite button. - Roles are loaded via `SettingsRolesQueryEffect` and `settingsAllRolesSelector`; only roles with `canBeAssignedToUsers` are shown. - Role is **required**: form validates `roleId` (e.g. `z.string().min(1)`) and the Invite button is disabled until a role is selected and emails are valid. - `WorkspaceInviteTeam` receives `roles` as a prop from the parent; layout is responsive (e.g. stacked on small viewports). - **Pending invitations table** - New **Role** column showing the invitation’s role label (or "Unknown role" for legacy invites without `roleId`), using the same roles source for lookup. - **Onboarding invite step** - When sending invites during onboarding, the workspace **default role** is used when available (`currentWorkspace?.defaultRole?.id`), so no role selector is added there. - **GraphQL** - `sendInvitations` mutation accepts optional `roleId`; `findWorkspaceInvitations` and resend mutation responses include `roleId` on `WorkspaceInvitation`. Frontend types (e.g. `WorkspaceInvitation`, hook variables) updated accordingly. --- ### Backend - **API** - `SendInvitationsInput` has an **optional** `roleId` (UUID, nullable). The resolver normalises `null` to `undefined` so existing callers and legacy flows are not broken. - **Validation (when `roleId` is provided)** - Role checks are centralised in **RoleValidationService** (`RoleValidationModule`, in `metadata-modules/role-validation/`). It validates that the role exists in the workspace and has `canBeAssignedToUsers`, and throws a permissions-style error otherwise. This avoids circular dependencies (e.g. `RoleModule` imports `UserWorkspaceModule`, so invite/accept flows cannot depend on `RoleModule`). - **Send flow:** `WorkspaceInvitationResolver` and `WorkspaceInvitationService.sendInvitations` both call `RoleValidationService.validateRoleAssignableToUsersOrThrow` when `roleId` is present (resolver before calling the service; service again before creating tokens so that **resend** also validates the stored role and fails fast if the role was deleted or made unassignable). - **Accept flow:** `UserWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace` uses the same service in `resolveRoleIdForNewMember` when an invitation provides a `roleId`, then falls back to `workspace.defaultRoleId` when not. Role/default is resolved and validated before any user/workspace/member creation. - **Persistence** - Invitation app tokens store `roleId` in `context` next to `email` (`context: { email, roleId? }`). `generateInvitationToken` and `createWorkspaceInvitation` accept an optional `roleId` and only add it to `context` when defined. - **Resend** - Resend passes the existing invitation’s `context.roleId` into `sendInvitations`. The service validates that role (when present) before creating the new token, so if the role was deleted or made unassignable, resend fails with a clear error instead of sending a broken link. - **Response shape** - `SendInvitationsOutput.result` remains `WorkspaceInvitation[]`. When `usePersonalInvitation` is false we only push full invitation records (from `castAppTokenToWorkspaceInvitationUtil`), so the result always matches the GraphQL type (`id`, `email`, `roleId`, `expiresAt`). - **Modules** - `WorkspaceInvitationModule` and `UserWorkspaceModule` import **RoleValidationModule** (not `RoleModule`) and inject **RoleValidationService** for validation. `RoleModule` imports `RoleValidationModule` and `RoleService` delegates to `RoleValidationService` for the same validation where the module graph allows. --- ### Backward compatibility - **Optional `roleId`**: Clients that don’t send `roleId` (or send `null`) are unchanged; invitations are created without a role and the accept flow uses the workspace default role. - **Legacy invitations**: App tokens with only `context.email` still work; `context.roleId` is optional and the UI can show e.g. "Unknown role" for those in the pending-invitations table. --- .../src/generated-metadata/graphql.ts | 17 +- .../components/SettingsRolesContainer.tsx | 6 +- .../components/SettingsRolesDefaultRole.tsx | 10 +- .../mutations/resendWorkspaceInvitation.ts | 1 + .../graphql/mutations/sendInvitations.ts | 5 +- .../queries/getWorkspaceInvitations.ts | 1 + .../useCreateWorkspaceInvitation.test.tsx | 18 +- .../hooks/useCreateWorkspaceInvitation.ts | 6 +- .../workspace-member/types/WorkspaceMember.ts | 1 + .../components/WorkspaceInviteTeam.tsx | 153 ++++-- .../src/pages/onboarding/InviteTeam.tsx | 6 + .../members/SettingsWorkspaceMembers.tsx | 450 ++++++++++-------- .../settings/security/SettingsSecurity.tsx | 13 +- .../app-token/app-token.entity.ts | 1 + .../auth/services/auth.service.ts | 1 + .../auth/services/sign-in-up.service.ts | 4 + .../user-workspace/user-workspace.module.ts | 2 + .../user-workspace.service.spec.ts | 7 + .../user-workspace/user-workspace.service.ts | 80 ++-- .../dtos/send-invitations.input.ts | 15 +- .../dtos/workspace-invitation.dto.ts | 3 + .../workspace-invitation.service.spec.ts | 7 + .../services/workspace-invitation.service.ts | 93 ++-- ...-app-token-to-workspace-invitation.spec.ts | 1 + ...-app-token-to-workspace-invitation.util.ts | 1 + .../workspace-invitation.module.ts | 2 + .../workspace-invitation.resolver.ts | 1 + .../role-validation/role-validation.module.ts | 12 + .../services/role-validation.service.ts | 45 ++ ...r-settings-permissions.integration-spec.ts | 5 +- 30 files changed, 630 insertions(+), 337 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/role-validation/role-validation.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/role-validation/services/role-validation.service.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d81de21f0c..989b96ce31 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3035,6 +3035,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = { export type MutationSendInvitationsArgs = { emails: Array; + roleId?: InputMaybe; }; @@ -5451,6 +5452,7 @@ export type WorkspaceInvitation = { email: Scalars['String']; expiresAt: Scalars['DateTime']; id: Scalars['UUID']; + roleId?: Maybe; }; export type WorkspaceInviteHashValid = { @@ -7172,19 +7174,20 @@ export type ResendWorkspaceInvitationMutationVariables = Exact<{ }>; -export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } }; +export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitations', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } }; export type SendInvitationsMutationVariables = Exact<{ emails: Array | Scalars['String']; + roleId?: InputMaybe; }>; -export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> } }; +export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitations', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> } }; export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, expiresAt: string }> }; +export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: string, email: string, roleId?: string | null, expiresAt: string }> }; export type DeletedWorkspaceMemberQueryFragmentFragment = { __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }; @@ -16582,6 +16585,7 @@ export const ResendWorkspaceInvitationDocument = gql` ... on WorkspaceInvitation { id email + roleId expiresAt } } @@ -16615,14 +16619,15 @@ export type ResendWorkspaceInvitationMutationHookResult = ReturnType; export type ResendWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions; export const SendInvitationsDocument = gql` - mutation SendInvitations($emails: [String!]!) { - sendInvitations(emails: $emails) { + mutation SendInvitations($emails: [String!]!, $roleId: UUID) { + sendInvitations(emails: $emails, roleId: $roleId) { success errors result { ... on WorkspaceInvitation { id email + roleId expiresAt } } @@ -16645,6 +16650,7 @@ export type SendInvitationsMutationFn = Apollo.MutationFunction { const { t } = useLingui(); - const settingsAllRoles = useSettingsAllRoles(); const settingsRolesIsLoading = useAtomStateValue(settingsRolesIsLoadingState); - if (settingsRolesIsLoading && !settingsAllRoles) { + if (settingsRolesIsLoading) { return null; } @@ -36,7 +33,6 @@ export const SettingsRolesContainer = () => { > - ); diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx index 154359ce3e..857fda519c 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx @@ -3,9 +3,10 @@ import { currentWorkspaceState, } from '@/auth/states/currentWorkspaceState'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers'; import { Select } from '@/ui/input/components/Select'; -import { t } from '@lingui/core/macro'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { H2Title, IconUserPin, useIcons } from 'twenty-ui/display'; import { Card, Section } from 'twenty-ui/layout'; @@ -13,7 +14,6 @@ import { type UpdateWorkspaceMutation, useUpdateWorkspaceMutation, } from '~/generated-metadata/graphql'; -import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers'; type SettingsRoleDefaultRoleProps = { roles: RoleWithPartialMembers[]; @@ -70,14 +70,14 @@ export const SettingsRoleDefaultRole = ({ return (
+ ); + }} + /> +
+ {shouldShowBypassSection && (
diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index 8941a5fb17..984ca4679c 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -91,6 +91,7 @@ export class AppTokenEntity { @Column({ nullable: true, type: 'jsonb' }) context: { email?: string; + roleId?: string; redirectUri?: string; clientId?: string; codeChallenge?: string; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index fcbaf14ffb..a8d350008f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -124,6 +124,7 @@ export class AuthService { await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( user, workspace, + invitation.context?.roleId, ); return; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index b7251a5010..872c3c431c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -208,6 +208,7 @@ export class SignInUpService { const updatedUser = await this.signInUpOnExistingWorkspace({ workspace: invitationValidation.workspace, userData: params.userData, + roleId: params.invitation.context?.roleId, }); await this.workspaceInvitationService.invalidateWorkspaceInvitation( @@ -256,6 +257,7 @@ export class SignInUpService { async signInUpOnExistingWorkspace( params: { workspace: WorkspaceEntity; + roleId?: string | null; } & ExistingUserOrPartialUserWithPicture, ) { await this.throwIfWorkspaceIsNotReadyForSignInUp(params.workspace, params); @@ -282,6 +284,7 @@ export class SignInUpService { await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( user, params.workspace, + params.roleId, ); return user; @@ -297,6 +300,7 @@ export class SignInUpService { await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( user, params.workspace, + params.roleId, ); return user; 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 1781ac439e..4c18b2bea7 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 @@ -21,6 +21,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent 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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @@ -36,6 +37,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works WorkspaceEntity, RoleTargetEntity, ]), + RoleValidationModule, NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]), TypeORMModule, DataSourceModule, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index d3f57a5344..74f2cc4296 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -26,6 +26,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac 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 { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -77,6 +78,12 @@ describe('UserWorkspaceService', () => { findOneOrFail: jest.fn(), }, }, + { + provide: RoleValidationService, + useValue: { + validateRoleAssignableToUsersOrThrow: jest.fn(), + }, + }, { provide: DataSourceService, useValue: { 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 4210b5acbd..88fad6cc2f 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 @@ -35,6 +35,7 @@ import { PermissionsExceptionCode, PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -51,6 +52,7 @@ export class UserWorkspaceService extends TypeOrmQueryService, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, + private readonly roleValidationService: RoleValidationService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly loginTokenService: LoginTokenService, @@ -148,47 +150,71 @@ export class UserWorkspaceService extends TypeOrmQueryService { + if (isDefined(roleId)) { + await this.roleValidationService.validateRoleAssignableToUsersOrThrow( + roleId, workspace.id, - user.email, ); - await this.onboardingService.setOnboardingCreateProfilePending({ - userId: user.id, - workspaceId: workspace.id, - value: true, - }); + return roleId; } + + const defaultRoleId = workspace.defaultRoleId; + + if (!isDefined(defaultRoleId)) { + throw new PermissionsException( + PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND, + PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND, + ); + } + + return defaultRoleId; } public async getUserCount(workspaceId: string): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts index 682a970ded..d14de01278 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts @@ -1,6 +1,14 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import { ArrayUnique, IsArray, IsEmail } from 'class-validator'; +import { + ArrayUnique, + IsArray, + IsEmail, + IsOptional, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @ArgsType() export class SendInvitationsInput { @@ -9,4 +17,9 @@ export class SendInvitationsInput { @IsEmail({}, { each: true }) @ArrayUnique() emails: string[]; + + @Field(() => UUIDScalarType, { nullable: true }) + @IsOptional() + @IsUUID() + roleId?: string | null; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts index 76f2f65f5c..3eda76f8f9 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts @@ -12,6 +12,9 @@ export class WorkspaceInvitation { @Field({ nullable: false }) email: string; + @Field(() => UUIDScalarType, { nullable: true }) + roleId?: string | null; + @Field({ nullable: false }) expiresAt: Date; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index c603b0b98d..067560d86d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -16,6 +16,7 @@ import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.se import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @@ -60,6 +61,12 @@ describe('WorkspaceInvitationService', () => { provide: getRepositoryToken(WorkspaceEntity), useClass: Repository, }, + { + provide: RoleValidationService, + useValue: { + validateRoleAssignableToUsersOrThrow: jest.fn(), + }, + }, { provide: WorkspaceDomainsService, useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 691d0329c9..6a3789a1a2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -35,6 +35,7 @@ import { WorkspaceInvitationExceptionCode, } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { CustomException } from 'src/utils/custom-exception'; @@ -45,6 +46,7 @@ export class WorkspaceInvitationService { private readonly appTokenRepository: Repository, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, + private readonly roleValidationService: RoleValidationService, private readonly twentyConfigService: TwentyConfigService, private readonly emailService: EmailService, private readonly onboardingService: OnboardingService, @@ -153,7 +155,11 @@ export class WorkspaceInvitationService { return appTokens.map(castAppTokenToWorkspaceInvitationUtil); } - async createWorkspaceInvitation(email: string, workspace: WorkspaceEntity) { + async createWorkspaceInvitation( + email: string, + workspace: WorkspaceEntity, + roleId?: string, + ) { const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation( workspace.id, email.toLowerCase(), @@ -185,7 +191,7 @@ export class WorkspaceInvitationService { ); } - return this.generateInvitationToken(workspace.id, email); + return this.generateInvitationToken(workspace.id, email, roleId); } async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) { @@ -238,14 +244,19 @@ export class WorkspaceInvitationService { await this.appTokenRepository.delete(appToken.id); - return this.sendInvitations([appToken.context.email], workspace, sender); + return this.sendInvitations( + [appToken.context.email], + workspace, + sender, + appToken.context.roleId, + ); } async sendInvitations( emails: string[], workspace: WorkspaceEntity, sender: WorkspaceMemberWorkspaceEntity, - usePersonalInvitation = true, + roleId?: string, ): Promise { if (!workspace?.inviteHash) { return { @@ -255,50 +266,45 @@ export class WorkspaceInvitationService { }; } + if (isDefined(roleId)) { + await this.roleValidationService.validateRoleAssignableToUsersOrThrow( + roleId, + workspace.id, + ); + } + await this.throttleInvitationSending(workspace.id, emails); - const invitationsPr = await Promise.allSettled( + const invitationResults = await Promise.allSettled( emails.map(async (email) => { - if (usePersonalInvitation) { - const appToken = await this.createWorkspaceInvitation( - email, - workspace, + const appToken = await this.createWorkspaceInvitation( + email, + workspace, + roleId, + ); + + if (!appToken.context?.email) { + throw new WorkspaceInvitationException( + 'Invalid email', + WorkspaceInvitationExceptionCode.EMAIL_MISSING, ); - - if (!appToken.context?.email) { - throw new WorkspaceInvitationException( - 'Invalid email', - WorkspaceInvitationExceptionCode.EMAIL_MISSING, - ); - } - - return { - isPersonalInvitation: true as const, - appToken, - email: appToken.context.email, - }; } - return { - isPersonalInvitation: false as const, - email, - }; + return { appToken, email: appToken.context.email }; }), ); - for (const invitation of invitationsPr) { + for (const invitation of invitationResults) { if (invitation.status === 'fulfilled') { const link = this.workspaceDomainsService.buildWorkspaceURL({ workspace, pathname: getAppPath(AppPath.Invite, { workspaceInviteHash: workspace?.inviteHash, }), - searchParams: invitation.value.isPersonalInvitation - ? { - inviteToken: invitation.value.appToken.value, - email: invitation.value.email, - } - : {}, + searchParams: { + inviteToken: invitation.value.appToken.value, + email: invitation.value.email, + }, }); if (!isDefined(sender.userEmail)) { @@ -360,15 +366,9 @@ export class WorkspaceInvitationService { const i18n = this.i18nService.getI18nInstance(sender.locale); - const result = invitationsPr.reduce<{ + const result = invitationResults.reduce<{ errors: string[]; - result: ReturnType< - typeof this.workspaceInvitationService.createWorkspaceInvitation - >['status'] extends 'rejected' - ? never - : ReturnType< - typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation - >; + result: ReturnType[]; }>( (acc, invitation) => { if (invitation.status === 'rejected') { @@ -381,9 +381,7 @@ export class WorkspaceInvitationService { } } else { acc.result.push( - invitation.value.isPersonalInvitation - ? castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken) - : { email: invitation.value.email }, + castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken), ); } @@ -398,7 +396,11 @@ export class WorkspaceInvitationService { }; } - async generateInvitationToken(workspaceId: string, email: string) { + async generateInvitationToken( + workspaceId: string, + email: string, + roleId?: string, + ) { const expiresIn = this.twentyConfigService.get( 'INVITATION_TOKEN_EXPIRES_IN', ); @@ -419,6 +421,7 @@ export class WorkspaceInvitationService { value: crypto.randomBytes(32).toString('hex'), context: { email, + ...(isDefined(roleId) ? { roleId } : {}), }, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts index eab60a0918..e1869822e9 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts @@ -55,6 +55,7 @@ describe('castAppTokenToWorkspaceInvitation', () => { expect(invitation).toEqual({ id: '1', email: 'test@example.com', + roleId: null, expiresAt: appToken.expiresAt, }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts index d1a39b7819..e89b1f3291 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts @@ -27,6 +27,7 @@ export const castAppTokenToWorkspaceInvitationUtil = ( return { id: appToken.id, email: appToken.context.email, + roleId: appToken.context.roleId ?? null, expiresAt: appToken.expiresAt, }; }; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts index c88b7fef67..45bf522f4a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts @@ -13,6 +13,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi UserWorkspaceEntity, WorkspaceEntity, ]), + RoleValidationModule, FileModule, OnboardingModule, PermissionsModule, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts index 74c321d061..52ebf23566 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts @@ -122,6 +122,7 @@ export class WorkspaceInvitationResolver { sendInviteLinkInput.emails, workspace, workspaceMember, + sendInviteLinkInput.roleId ?? undefined, ); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/role-validation/role-validation.module.ts b/packages/twenty-server/src/engine/metadata-modules/role-validation/role-validation.module.ts new file mode 100644 index 0000000000..53bf67aef8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role-validation/role-validation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([RoleEntity])], + providers: [RoleValidationService], + exports: [RoleValidationService], +}) +export class RoleValidationModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/role-validation/services/role-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/role-validation/services/role-validation.service.ts new file mode 100644 index 0000000000..a9d11b7031 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role-validation/services/role-validation.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; + +@Injectable() +export class RoleValidationService { + constructor( + @InjectRepository(RoleEntity) + private readonly roleRepository: Repository, + ) {} + + async validateRoleAssignableToUsersOrThrow( + roleId: string, + workspaceId: string, + ): Promise { + const role = await this.roleRepository.findOne({ + where: { + id: roleId, + workspaceId, + }, + }); + + if (!role) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_NOT_FOUND, + PermissionsExceptionCode.ROLE_NOT_FOUND, + ); + } + + if (!role.canBeAssignedToUsers) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_CANNOT_BE_ASSIGNED_TO_USERS, + PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS, + ); + } + } +} diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts index 953a0ce1d0..50f365d008 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts @@ -276,7 +276,10 @@ describe('Granular settings permissions', () => { const inviteWorkspaceMemberQuery = { query: ` mutation SendWorkspaceInvitation { - sendInvitations(emails: ["test@example.com"]) { + sendInvitations( + emails: ["test@example.com"], + roleId: "${originalMemberRoleId}" + ) { success } }