https://sonarly.com/issue/3776?type=bug
The `POST /metadata` endpoint handling `GetCurrentUser` takes 3.6 seconds because `getRolesByUserWorkspaces()` is called 3 times redundantly within the same request, and multiple core entities (User, UserWorkspace) are re-queried across auth middleware and resolver fields without request-scoped caching.
Fix: ## Fix: Eliminate redundant `getRolesByUserWorkspaces()` DB call in `workspaceMember()` resolver
The root cause is that `getRolesByUserWorkspaces()` is called 3 times per `GetCurrentUser` request, each executing an expensive `RoleTargetEntity` JOIN query (~533ms each, ~1,600ms total). Calls #1 and #2 fetch data for the **exact same** `userWorkspaceId` (the currently authenticated user's workspace).
### Changes
**`permissions.service.ts`** — Add optional `preloadedRoles` parameter to `getUserWorkspacePermissions()`:
```typescript file=packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts lines=38-54
public async getUserWorkspacePermissions({
userWorkspaceId,
workspaceId,
preloadedRoles,
}: {
userWorkspaceId: string;
workspaceId: string;
preloadedRoles?: Map<string, RoleEntity[]>;
}): Promise<UserWorkspacePermissions> {
const [roleOfUserWorkspace] = preloadedRoles
? (preloadedRoles.get(userWorkspaceId) ?? [])
: await this.userRoleService
.getRolesByUserWorkspaces({ userWorkspaceIds: [userWorkspaceId], workspaceId })
.then((roles) => roles?.get(userWorkspaceId) ?? []);
```
**`user.resolver.ts`** — Refactor private helper to fetch roles once and return them alongside permissions, then store the result on the user object for reuse by `workspaceMember()`:
```typescript file=packages/twenty-server/src/engine/core-modules/user/user.resolver.ts lines=98-134
private async getUserWorkspacePermissions({...}): Promise<{
userWorkspacePermissions: UserWorkspacePermissions;
rolesCache: Map<string, RoleEntity[]> | undefined;
}> {
// ...pending workspace check...
const rolesCache = await this.userRoleService.getRolesByUserWorkspaces({
userWorkspaceIds: [currentUserWorkspace.id],
workspaceId: workspace.id,
});
const userWorkspacePermissions =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId: currentUserWorkspace.id,
workspaceId: workspace.id,
preloadedRoles: rolesCache, // skip internal DB fetch
});
return { userWorkspacePermissions, rolesCache };
}
```
`currentUser()` stores `rolesCache` on the returned object:
```typescript
return {
...user,
currentUserWorkspace: { ...currentUserWorkspace, ...userWorkspacePermissionsDto, ... },
currentWorkspace: workspace,
__rolesCache: rolesCache, // available to workspaceMember() ResolveField
} as UserEntity;
```
`workspaceMember()` uses cached roles when available, skipping the redundant DB call:
```typescript file=packages/twenty-server/src/engine/core-modules/user/user.resolver.ts lines=254-263
const cachedRoles = (
user as UserEntity & { __rolesCache?: Map<string, RoleEntity[]> }
).__rolesCache;
const roleOfUserWorkspace =
cachedRoles ??
(await this.userRoleService.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspace.id],
workspaceId,
}));
```
### Result
- **Before**: 3 `RoleTargetEntity` DB queries per `GetCurrentUser` request (~1,600ms)
- **After**: 2 `RoleTargetEntity` DB queries (Call #1 + Call #3 remain; Call #2 eliminated when `__rolesCache` is populated by `currentUser()`)
- The fallback in `workspaceMember()` ensures correctness when called outside the `currentUser()` context (cache miss gracefully falls back to DB)
- No changes to the `workspaceMembers()` call path (Call #3 remains independent)
Workflow crons take a few minutes to run. Loading each repo takes ~200
to 300ms locally. Adding a lite mode so it takes less than 100ms.
Also doing batch promises.
Finally, cleaning runs timeout when there are too many. Doing batches as
well.
## Summary
- add a working `Forgot your password?` flow on `app.twenty.com` sign-in
- keep existing workspace-domain reset behavior
- when triggered without workspace context, resolve a workspace from the
user when possible, otherwise fallback to `app.twenty.com` reset URL
## Backend
- make `workspaceId` optional in `emailPasswordResetLink` input
- allow nullable `workspaceId` in password reset token DTO
- update reset token generation to accept optional `workspaceId`
- when missing, resolve first workspace by user membership
- if no workspace is found, persist token with `workspaceId = null`
- send reset links via:
- workspace URL when `workspaceId` exists
- app front URL + reset path when `workspaceId` is null
## Frontend
- make reset-link mutation `workspaceId` variable optional
- regenerate/patched generated metadata types accordingly
- add `Forgot your password?` in global password step
- allow reset request without workspace context in
`useHandleResetPassword`
- make reset page auto sign-in domain-aware (`workspace` vs `app`)
- apply design-system spacing above the global forgot-password link
(`theme.spacing(4)`)
## Tests
- extend reset-password service tests for:
- explicit workspace id
- inferred workspace when workspace id is missing
- app-domain fallback when no workspace is found
- extend reset-password hook tests for with/without workspace context
- add focused global form test for forgot-password link rendering/click
behavior
## Product behavior for users with multiple workspaces
- no workspace chooser is shown in this flow
- backend uses the first resolvable workspace membership for the
reset-link domain
- password change remains account-level and works across all workspaces
Feature has been tested and is working
<img width="3268" height="2106" alt="CleanShot 2026-02-26 at 14 09
14@2x"
src="https://github.com/user-attachments/assets/b5db3bed-f3aa-4d35-b54e-66e4d99141f9"
/>
---------
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
## PR Description
- Uses `expr-eval` to enable front components (SDK plugins) to define
conditional availability as declarative expressions.
- Moves shared types and constants to `twenty-shared`
- Introduces a `conditionalAvailabilityExpression` field on
`CommandMenuItemEntity`, allowing command menu items to store an
`expr-eval` compatible expression string that is evaluated against a
CommandMenuContext to determine if the item should be shown.
- Creates an esbuild transform plugin
`conditional-availability-transform-plugin` in `twenty-sdk` that
converts TypeScript conditional availability expressions into
`expr-eval` compatible syntax at build time, so SDK developers can write
natural TS expressions that get transformed to evaluable strings.
- Removes deprecated `forceRegisteredActionsByKey` state and its usage.
- Creates `useCommandMenuContext` hook that builds the full
`CommandMenuContext` object from React state, which is then passed to
`useCommandMenuItemFrontComponentActions` for evaluating conditional
availability expressions.
Closes https://github.com/twentyhq/core-team-issues/issues/1627
**FilterArgProcessor consolidation:**
Refactored to both validate AND transform filter values in a single pass
Coerced string inputs to native types (e.g., "1" → 1, "true" → true -
useful for Rest input)
Returns transformed filter instead of just validating
Removed overrideFilterByFieldMetadata calls from all computeArgs methods
**QueryRunnerArgsFactory cleanup**
**Testing:**
Add unit testing
uncomment integration tests
- apollo enrich application (via OAuth 2)
- add applicationId to var env in logic function executor
- update `getDefaultUrl` logic
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
<img width="450" height="212" alt="Capture d’écran 2026-03-03 à 11 41
54"
src="https://github.com/user-attachments/assets/b2c29a48-7dc0-4b16-a085-8f305d21f7ca"
/>
New status `FAIL_SAFE` added. This status propagates to the following
nodes until reaching the iterator, that will start the new iteration.
The difference with `SKIP` is that, when the parent nodes have at least
one `FAIL_SAFE`, it becomes `FAIL_SAFE` too. While a parent 1 `SKIP` +
parent 2 `SUCCESS` => to be executed.
I also thought about just going back to the iterator as a break would,
but since we have branches, it may lead to inconsistent statuses with
parallel updates.
# Introduction
We need to build and validate the flat entity operation in the following
order delete update and create
For example if not, if a created field has the same name than a deleted
one than it will fail whereas it should not
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.
## Context
Part 1 of migrating gridPosition in favor of typed position
FE should now always send both values to the BE and use both.
Next steps:
- Update the backend to enforce and validate the new position field + DB
migrations gridPositon -> position (type: GRID)
- Cleanup frontend usage
- Cleanup backend
If-else branches cannot be recreated once deleted. Only else-if branches
can. On if-else branches removal, we now remplace the node by an empty
node instead of only deleting
Also fixing nested if-else.
## Summary
Follow-up to #18267. Hardens the OAuth implementation with security
fixes identified during audit:
**P0 — Critical:**
- Bind authorization codes to `client_id` in context to prevent auth
code injection (RFC 6749 §4.1.3)
- Store PKCE `code_challenge` directly in auth code context instead of a
separate `CodeChallenge` token — cryptographically binds the challenge
to its code
- Enforce `code_verifier` when `code_challenge` was used during
authorization
- Hash authorization codes (SHA-256) before storage to prevent exposure
if DB is compromised
- Add `Cache-Control: no-store` + `Pragma: no-cache` headers on token
responses (RFC 6749 §5.1)
- Add rate limiting on `/oauth/token` endpoint (20 req/min per client
via existing `ThrottlerService`)
**P1 — High:**
- Return HTTP 401 for `invalid_client` errors instead of 400 (RFC 6749
§5.2)
- Verify refresh tokens belong to the presenting client (cross-client
token theft prevention)
- Limit fields exposed by public `findApplicationRegistrationByClientId`
query to only what the frontend needs (`id`, `name`, `logoUrl`,
`websiteUrl`, `oAuthScopes`)
- Require `API_KEYS_AND_WEBHOOKS` permission for
`createApplicationRegistration` mutation
**P2/P3 — Medium/Low:**
- Add error handling and loading states to frontend Authorize page
- Rename redirect URL param from `authorizationCode` to `code` (RFC
standard)
- Add unit tests for `validateRedirectUri` utility (8 test cases)
## Test plan
- [ ] Existing OAuth integration tests updated for all changes (hashed
codes, context-based PKCE, client binding, 401 status codes, cache
headers)
- [ ] New test: auth code rejected when presented by a different client
- [ ] New test: refresh token rejected when presented by a different
client
- [ ] New test: `code_verifier` required when PKCE was used in
authorization
- [ ] New test: `Cache-Control: no-store` header present on responses
- [ ] New unit tests for `validateRedirectUri` (HTTPS, localhost,
fragments, invalid URIs)
- [ ] Verify frontend authorize page shows errors gracefully
Made with [Cursor](https://cursor.com)
- Fixes self host application
- add new telemetry information
- add serverId to identify a server instance
- remove .twenty from git tracking
- tree-shake "twenty-sdk" usage in built logic functions and front
components
- fix "twenty-sdk" version usage
- fix twenty-zapier cli
---------
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Adds color support for navigation menu items.
---------
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Devessier <baptiste@devessier.fr>
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
Add Home/Chat tabs and a dedicated threads list in the navigation
drawer.
## Changes
- **Navbar tabs:** Tabs in the drawer to switch between Home and Chat
(with “New chat” button). Shown on desktop when expanded and on mobile
below the workspace selector.
- **Navbar threads list:** New `NavigationDrawerAIChatThreadsList` for
the Chat tab with date groups (Today / Yesterday / Older), thread rows
as `NavigationDrawerItem` (IconComment, title, timestamp). Shared
`useAIChatThreadClick` hook used by navbar and command menu; navbar
passes `resetNavigationStack: true`.
- **NavigationDrawerItem:** New `alwaysShowRightOptions` prop so the
timestamp is always visible (no hover-only).
---------
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This PR pgrades all BlockNote packages (@blocknote/core,
@blocknote/react, @blocknote/mantine, @blocknote/server-util,
@blocknote/xl-docx-exporter, @blocknote/xl-pdf-exporter) to 0.47.0 and
adapts the codebase to the new API.
### Changes
- Dependency upgrades: Bumped all BlockNote packages to 0.47.0, added
required Mantine v8 peer dependencies, removed unnecessary prosemirror
resolutions
- Formatting toolbar: Replaced the manual reimplementation of
FormattingToolbarController (which handled visibility, positioning,
portal rendering, text-alignment-based placement, and a
dangerouslySetInnerHTML transition trick) with BlockNote's built-in
FormattingToolbarController. The toolbar buttons themselves are
unchanged.
- Side menu: Replaced manual drag handle menu positioning and rendering
(DashboardBlockDragHandleMenu, DashboardBlockColorPicker, and their
floating configs) with BlockNote's built-in SideMenuController,
DragHandleButton, and DragHandleMenu components. Deleted 4 files that
became dead code.
- Extension API migration: Replaced deprecated editor.suggestionMenus
and editor.formattingToolbar APIs with the new extension system
(SuggestionMenu, useExtensionState, editor.getExtension())
- Slash menu fixes: Filtered out BlockNote's new default "File" item
(added in 0.47) to avoid duplicates with our custom one; added icon
mappings for new block types (Toggle List, Divider, Toggle Headings,
Headings 4-6)
- Server-side: Switched @blocknote/server-util to dynamic import() to
handle ESM-only transitive dependencies in CJS context
# Introduction
Resolving each create|updated|deleted entities related entities
application through their universal foreingKey ( silently failing if not
found leaving the validator handling that )
In order to compute all the required application in the dependency flat
entity maps
## Tested
Creating a field on a custom local twenty-apps installed on the
workspace + view field
## Out of scope
- updated snapshot
- update graphql generated front
## Automated fix for [bug 5360](https://sonarly.com/issue/5360?type=bug)
**Severity:** `critical`
### Summary
When an existing user created via OAuth (with no password hash) attempts
to sign in through the signInUp password flow, their null passwordHash
is passed directly to bcrypt.compare, which throws an unhandled 'data
and hash arguments required' error instead of a user-friendly message.
### User Impact
Users who originally registered via Google or Microsoft OAuth and then
attempt to sign in using email/password through the signInUp flow cannot
authenticate. They see a server error instead of a helpful message like
'User was not created with email/password'. This blocks the user from
accessing the CRM entirely.
### Root Cause
Proximate cause: bcrypt.compare() at auth.util.ts:19 throws 'data and
hash arguments required' because the passwordHash argument is null.
1. Why did bcrypt throw? Because compareHash() was called with a null
passwordHash value. The compareHash function at auth.util.ts:19 passes
its arguments directly to bcrypt.compare without any null check.
2. Why was passwordHash null? Because SignInUpService.validatePassword()
at sign-in-up.service.ts:154 received a null passwordHash from
AuthService.validatePassword() at auth.service.ts:232. The value
userData.existingUser.passwordHash is null for users who were originally
created via an OAuth provider (Google, Microsoft) and never set a
password.
3. Why was there no null check before calling bcrypt? Because
AuthService.validatePassword() at line 229-233 does not check whether
userData.existingUser.passwordHash is null before passing it to
signInUpService.validatePassword(). The TypeScript type annotation says
passwordHash is string, but the UserEntity column is declared nullable:
true (user.entity.ts:73), so the runtime value can be null despite the
type system.
4. Why does this code path lack the null check when another path has it?
The validateLoginWithPassword() method at auth.service.ts:177 correctly
checks if (!user.passwordHash) and throws a user-friendly AuthException
'Incorrect login method'. This guard was added by Thomas Trompette on
2024-08-07 (commit 2abb6adb61). However, the signInUp flow's
validatePassword() method was introduced later by Antoine Moreaux on
2025-03-17 (commit bda835b9f8) without replicating this same defensive
check.
5. Why was this not caught? The signInUp validatePassword method was
written assuming the existingUser would always have a passwordHash when
the auth provider is Password. This assumption is incorrect because the
signInUp flow can match an existing OAuth-created user (who has no
passwordHash) when someone tries to sign up with email/password using an
email already registered via OAuth. No test covers this cross-provider
scenario in the signInUp path.
Root cause: The AuthService.validatePassword() method
(auth.service.ts:229-233) in the signInUp code path is missing a null
guard on userData.existingUser.passwordHash before passing it to
bcrypt.compare. The fix should check for null/undefined passwordHash and
throw a descriptive AuthException, mirroring the existing guard in
validateLoginWithPassword at line 177.
**Introduced by:** Antoine Moreaux on 2025-03-17 in commit
[`bda835b`](https://github.com/twentyhq/twenty/commit/bda835b9f82)
### Suggested Fix
Added a null check on userData.existingUser.passwordHash inside the
validatePassword private method of AuthService, immediately before
calling signInUpService.validatePassword. When passwordHash is null or
undefined (i.e. the user was created via OAuth and never set a
password), an AuthException with code INVALID_INPUT is thrown with the
user-friendly message 'User was not created with email/password'. This
mirrors the identical guard that already exists in
validateLoginWithPassword at line 177, preventing bcrypt.compare from
receiving a null argument and crashing with an unhandled error.
### Evidence
- **Code:**
[packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts:229
- if (userData.type === 'existingUser')
{](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L229)
---
*Generated by [Sonarly](https://sonarly.com)*
Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>