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.
The #1 Open-Source CRM
🌐 Website · 📚 Documentation · Roadmap ·
Discord ·
Figma
Installation
See: 🚀 Self-hosting 🖥️ Local Setup
Why Twenty
We built Twenty for three reasons:
CRMs are too expensive, and users are trapped. Companies use locked-in customer data to hike prices. It shouldn't be that way.
A fresh start is required to build a better experience. We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
We believe in Open-source and community. Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
What You Can Do With Twenty
Please feel free to flag any specific needs you have by creating an issue.
Below are a few features we have implemented to date:
- Personalize layouts with filters, sort, group by, kanban and table views
- Customize your objects and fields
- Create and manage permissions with custom roles
- Automate workflow with triggers and actions
- Emails, calendar events, files, and more
Personalize layouts with filters, sort, group by, kanban and table views
Customize your objects and fields
Create and manage permissions with custom roles
Automate workflow with triggers and actions
Emails, calendar events, files, and more
Stack
- TypeScript
- Nx
- NestJS, with BullMQ, PostgreSQL, Redis
- React, with Jotai, Emotion and Lingui
Thanks
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on Twitter or LinkedIn
- Join our Discord
- Improve translations on Crowdin
- Contributions are, of course, most welcome!




