feat: introduce role selector when inviting members to a workspace (#18085)

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.
This commit is contained in:
Abdullah.
2026-03-02 22:58:32 +05:00
committed by GitHub
parent 1eb284c87f
commit 20a2c3836e
30 changed files with 630 additions and 337 deletions
@@ -91,6 +91,7 @@ export class AppTokenEntity {
@Column({ nullable: true, type: 'jsonb' })
context: {
email?: string;
roleId?: string;
redirectUri?: string;
clientId?: string;
codeChallenge?: string;
@@ -124,6 +124,7 @@ export class AuthService {
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
user,
workspace,
invitation.context?.roleId,
);
return;
@@ -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;
@@ -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,
@@ -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: {
@@ -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<UserWorkspaceEntit
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
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<UserWorkspaceEntit
async addUserToWorkspaceIfUserNotInWorkspace(
user: UserEntity,
workspace: WorkspaceEntity,
roleId?: string | null,
) {
let userWorkspace = await this.checkUserWorkspaceExists(
const existingUserWorkspace = await this.checkUserWorkspaceExists(
user.id,
workspace.id,
);
if (!userWorkspace) {
userWorkspace = await this.create({
userId: user.id,
workspaceId: workspace.id,
isExistingUser: true,
});
if (existingUserWorkspace) {
return;
}
await this.createWorkspaceMember(workspace.id, user);
const resolvedRoleId = await this.resolveRoleIdForNewMember(
roleId,
workspace,
);
const defaultRoleId = workspace.defaultRoleId;
const userWorkspace = await this.create({
userId: user.id,
workspaceId: workspace.id,
isExistingUser: true,
});
if (!isDefined(defaultRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND,
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
);
}
await this.createWorkspaceMember(workspace.id, user);
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId: workspace.id,
userWorkspaceIds: [userWorkspace.id],
roleId: defaultRoleId,
});
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId: workspace.id,
userWorkspaceIds: [userWorkspace.id],
roleId: resolvedRoleId,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
);
await this.onboardingService.setOnboardingCreateProfilePending({
userId: user.id,
workspaceId: workspace.id,
value: true,
});
}
private async resolveRoleIdForNewMember(
roleId: string | null | undefined,
workspace: WorkspaceEntity,
): Promise<string> {
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<number | undefined> {
@@ -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;
}
@@ -12,6 +12,9 @@ export class WorkspaceInvitation {
@Field({ nullable: false })
email: string;
@Field(() => UUIDScalarType, { nullable: true })
roleId?: string | null;
@Field({ nullable: false })
expiresAt: Date;
}
@@ -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: {
@@ -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<AppTokenEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
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<SendInvitationsDTO> {
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<typeof castAppTokenToWorkspaceInvitationUtil>[];
}>(
(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 } : {}),
},
});
@@ -55,6 +55,7 @@ describe('castAppTokenToWorkspaceInvitation', () => {
expect(invitation).toEqual({
id: '1',
email: 'test@example.com',
roleId: null,
expiresAt: appToken.expiresAt,
});
});
@@ -27,6 +27,7 @@ export const castAppTokenToWorkspaceInvitationUtil = (
return {
id: appToken.id,
email: appToken.context.email,
roleId: appToken.context.roleId ?? null,
expiresAt: appToken.expiresAt,
};
};
@@ -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,
@@ -122,6 +122,7 @@ export class WorkspaceInvitationResolver {
sendInviteLinkInput.emails,
workspace,
workspaceMember,
sendInviteLinkInput.roleId ?? undefined,
);
}
}