This PR fixes a bug which could happen if two people were editing a page
layout at the same time.
If one person deletes a widget or a tab and saves first, and the second
person saves after but doesn't delete this widget or tab, an error is
raised.
This is because, in the backend, a diff is computed to know which tab or
widget to create, update or delete. But the logic was maid without
considering soft deletion. So, when the second person saves, the diff
tries to create the widget or tab which had been soft deleted by the
first use. Since they have the same id, a duplicate primary key error is
raised.
Now we always consider that the last user to save has the truth. So we
restore the tab/widget and update it.
This PR fixes a crash of the app when a Snackbar tries to render an
object.
<img width="3002" height="754" alt="image"
src="https://github.com/user-attachments/assets/988f97eb-5c8a-44f8-ac8e-e2953b872bac"
/>
What happened : the backend sent a MessageDescriptor in
`extensions.userFriendlyMessage` while it should have sent a translated
string.
But it is a problem that the Snackbar can end up rendering objects and
crashing the whole app because it is a the highest level in the tree.
So this PR hardens many points to avoid future bugs :
- Sanitize what is rendered by the Snackbar to avoid any crash
- Translate any MessageDescriptor object that could end up in the
frontend
- Fix the places in the backend where a MessageDescriptor wasn't
translated and sent directly to the frontend in
`use-graphql-error-handler.hook.ts`
Fixes https://github.com/twentyhq/twenty/issues/15685
Fixes https://github.com/twentyhq/core-team-issues/issues/1869
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Pass `shouldBypassPermissionChecks: true` to
`getRepositoryForWorkspace('workspaceMember')` in
`1-11-clean-orphaned-user-workspaces.command.ts` to ensure member lookup
during orphan cleanup ignores permissions.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
543fd9fc01a9b56e42cd1241e0c12c214f8317de. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
## Context
This PR introduces a Global Workspace DataSource that consolidates
workspace-specific database access through a single TypeORM DataSource
instance with AsyncLocalStorage-based context management instead of N
workspace datasources.
- Created GlobalWorkspaceDataSource extending TypeORM's DataSource to
manage multiple workspaces with entity metadata caching (1-hour TTL)
- Implemented AsyncLocalStorage for workspace context propagation
(WorkspaceContextForStorage) containing workspace ID, metadata,
permissions, and feature flags
- Modified query execution flow to wrap operations in workspace context
via GlobalWorkspaceOrmManager.executeInWorkspaceContext()
- Added schema name to entity schemas for proper multi-tenant database
separation
Next:
- use the new global workspace datasource everywhere and deprecate
workspace datasource factory
- improve metadata caching using a short TTL to avoid multiple calls to
redis
- Leverage the new WorkspaceContextALS and put it higher in the request
hierarchy to have access to permission, metadata and featureflag
everywhere --- build it manually for commands --- find a way to
propagate it in jobs?
- Remove PG_POOL patch once we have a unique datasource and increase
global datasource pool size
## Implementation
Why ALS:
1. Automatic Per-Request Isolation
With schema-based multi-tenancy, each workspace has its own PostgreSQL
schema
(e.g. workspace_20202020-1c25-4d02-bf25-6aeccf7ea419).
The critical challenge is ensuring that concurrent requests from
different tenants don't interfere with each other.
```typescript
// Request A (Workspace 1) and Request B (Workspace 2) executing concurrently
// Without ALS: Race condition — they'd share the same global state!
// With ALS: Each request has isolated context ✓
```
ALS automatically isolates context per async execution chain, so:
- Request from Tenant A → ALS stores workspaceId: "tenant-a" → Queries
hit workspace_tenant_a schema
- Request from Tenant B → ALS stores workspaceId: "tenant-b" → Queries
hit workspace_tenant_b schema
✅ No interference, even when executing simultaneously on the same
Node.js event loop.
2. No Manual Context Passing
Before ALS, you'd need to pass workspaceId through every function call:
```typescript
// ❌ Without ALS - Context threading nightmare
getRepository(workspaceId, entity)
→ createEntityManager(workspaceId)
→ getMetadata(workspaceId, target)
→ findInCache(workspaceId, cacheKey)
```
With ALS:
```typescript
// ✅ With ALS - Clean, implicit context
getRepository(entity) // Reads workspaceId from ALS
→ createEntityManager() // Reads workspaceId from ALS
→ getMetadata(target) // Reads workspaceId from ALS
→ findInCache(cacheKey) // Reads workspaceId from ALS
```
example
```typescript
override findMetadata(target: EntityTarget<ObjectLiteral>): EntityMetadata | undefined {
const context = getWorkspaceContext(); // 👈 Automatically gets the right workspace!
const { workspaceId, metadataVersion } = context;
const cacheKey = `${workspaceId}-${metadataVersion}`;
// ... returns metadata for THIS workspace's schema
}
```
3. Async Chain Propagation
Node.js operations are heavily async. ALS automatically propagates
context through:
- async/await chains
- Promise chains
- Callbacks
```typescript
executeInWorkspaceContext(workspaceId, async () => {
await prepareContext(); // Has context ✓
const results = await run(); // Has context ✓
await enrichResults(); // Has context ✓
// Even nested async operations maintain context!
await Promise.all([
saveToCache(), // Has context ✓
emitEvent(), // Has context ✓
logMetrics(), // Has context ✓
]);
});
```
5. Schema-Specific Metadata Caching
The implementation caches entity metadata per workspace + version:
```typescript
// Cache key format: "workspaceId-metadataVersion"
const cacheKey = `${workspaceId}-${metadataVersion}`;
```
Why this matters with schemas:
- Each workspace has different table structures (custom fields, objects)
- EntitySchema includes schema: "workspace_xxx" property
- Each cached metadata points to the correct schema
ALS ensures getWorkspaceContext() returns the right workspaceId,
so you always get the correct schema's metadata from cache.
6. Single DataSource for All Tenants
The key change here:
```typescript
// ❌ Old approach: One DataSource per tenant
const dataSourceTenantA = new DataSource({ schema: 'workspace_a' });
const dataSourceTenantB = new DataSource({ schema: 'workspace_b' });
// Problem: Hundreds of DB connection pools!
```
```typescript
// ✅ New approach: One shared DataSource + ALS context
const globalDataSource = new GlobalWorkspaceDataSource();
// ALS determines which schema to use at runtime
```
When you call:
```typescript
globalDataSource.getRepository('person');
```
It internally does:
```typescript
const context = getWorkspaceContext(); // Gets current tenant from ALS
const metadata = this.findMetadata('person'); // Finds metadata for THIS tenant's schema
// EntityMetadata includes: schema: "workspace_20202020-1c25..."
// TypeORM automatically queries: SELECT * FROM "workspace_20202020-1c25...".person
```
7. Request Lifecycle Example
```typescript
// 1. GraphQL request arrives: "query people { ... }"
// 2. Middleware extracts authContext.workspace.id = "tenant-a"
// 3. Query runner wraps execution in ALS:
executeInWorkspaceContext("tenant-a", async () => {
// 4. Everything inside has access to workspace context:
const repo = getRepository('person'); // ALS → tenant-a
const metadata = getMetadata('person'); // ALS → tenant-a → cache["tenant-a-v5"]
// 5. TypeORM builds query with correct schema:
// SELECT * FROM "workspace_tenant_a"."person" WHERE ...
// 6. Even nested calls work:
await saveAuditLog(); // ALS → tenant-a → correct audit schema
await emitWebhook(); // ALS → tenant-a → correct tenant webhook
});
// 7. Request completes, ALS context automatically cleaned up
```
8. Safety & Error Prevention
```typescript
// If you forget to set context:
const context = getWorkspaceContext();
// ❌ Throws: "Workspace context not set..."
// Fails fast rather than querying wrong schema!
// Can't accidentally query wrong tenant:
// Context is immutable within execution scope
```
# Introduction
related to https://github.com/twentyhq/core-team-issues/issues/1833
In this PR we're starting the sync-metadata and standardIds deprecation
by introducing `twenty-standard` application that will regroup every
standard object such as company and opportunities. But also the
`custom-workspace-application` which is an app created at the same time
as a workspace and that will regroup everything configure within the
workspace ( custom objects fields etc )
## What's done
On both new workspace and seeded workspace creation:
- Creating a custom workspace app
- Creating a twenty standard app
- Refactored the seed core schema and workspace creation to be run
within a transaction in order to handle circular dependency foreignkey
requirements ( which is deferred for app toward workspace )
- Updated workspace entity to have a custom workspace relation (
nullable for the moment until we implem an upgrade command to handle
retro comp )
- Integration testing on user, workspace creation deletion and expected
default apps creation
- ~~Soft deleted user on `deleteUser`~~ Done by marie and rebased on it
## What's next
- Update seeder to propagate the `twenty-standard` workspace
`applicationId` to every standard synchronized entities ( cheap and fast
iteration through the about to be deprecated sync-metadata as an easy
way to synchronize standards metadata entities ).
- Update seeder to propagate the `custom-workspace-application`
workspace `applicationId` to anything custom ( `pets` and `rockets` )
- Prepend `custom-workspace-application` `applicationId` to every
metadata API operations ( create a specific cache etc )
- Upgrade command on all existing workspace to create a custom app and
associate its applicationId to any existing custom entities
- Make `universalIdentifier` and `applicationId` required for any
syncable entity
**Root Cause**
Many-to-one relation filters were being exposed under the relation name
(e.g., `company`) instead of the corresponding foreign-key attribute
(e.g., `companyId`).
**Change**
- Detect many-to-one relation metadata and remap those filter keys to
`<fieldName>Id`.
- Removed some unused code unrelated to this fix.
## Problem
The ESLint rule `graphql-resolvers-should-be-guarded` introduced in
#15392 was failing on main because the guard `SettingsPermissionsGuard`
had inconsistent naming.
## Root Cause
The guard was named `SettingsPermissionsGuard` (with an 's') which was
inconsistent with other permission guards:
- ✅ `CustomPermissionGuard`
- ✅ `NoPermissionGuard`
- ✅ `ImpersonatePermissionGuard`
- ❌ `SettingsPermissionsGuard` (inconsistent!)
The ESLint rule checks if guard names end with `PermissionGuard`, but
`SettingsPermissionsGuard` ends with `sGuard`, so it wasn't recognized
as a permission guard.
## Solution
Renamed the guard to be consistent with the naming convention:
1. ✅ Renamed file: `settings-permissions.guard.ts` →
`settings-permission.guard.ts`
2. ✅ Renamed export: `SettingsPermissionsGuard` →
`SettingsPermissionGuard`
3. ✅ Renamed internal class: `SettingsPermissionsMixin` →
`SettingsPermissionMixin`
4. ✅ Updated all 122 references across 44 files in the codebase
5. ✅ Renamed test file: `settings-permissions.guard.spec.ts` →
`settings-permission.guard.spec.ts`
## Testing
- ✅ `npx nx run twenty-server:lint` passes
- ✅ `npx nx run twenty-server:typecheck` passes
- ✅ No references to the old name remain in the codebase
- ✅ All previously failing resolver files now pass ESLint validation
## Related
Fixes issues introduced in #15392
## Overview
This PR strengthens our permission system by introducing more granular
role-based access control across the platform.
## Changes
### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)
### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation
### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls
### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic
## Testing
- ✅ All TypeScript type checks pass
- ✅ ESLint validation passes
- ✅ New permission guards properly enforced
- ✅ Frontend UI displays new permissions correctly
## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
Until this is done: [Make workspaceMembers
non-system](https://github.com/twentyhq/twenty/issues/15688)
Let's make that update to allow us to have workspaces with
workspaceMember not being system object, to allow user to customize
their data model.
**Before**
- any user with workpace_members permission was able to remove a user
from their workspace. This triggered the deletion of workspaceMember +
of userWorkspace, but did not delete the user (even if they had no
workspace left) nor the roleTarget (acts as junction between role and
userWorkspace) which was left with a userWorkspaceId pointing to
nothing. This is because roleTarget points to userWorkspaceId but the
foreign key constraint was not implemented
- any user could delete their own account. This triggered the deletion
of all their workspaceMembers, but not of their userWorkspace nor their
user nor the roleTarget --> we have orphaned userWorkspace, not
technically but product wise - a userWorkspace without a workspaceMember
does not make sense
So the problems are
- we have some roleTargets pointing to non-existing userWorkspaceId
(which caused https://github.com/twentyhq/twenty/issues/14608 )
- we have userWorkspaces that should not exist and that have no
workspaceMember counterpart
- it is not possible for a user to leave a workspace by themselves, they
can only leave all workspaces at once, except if they are being removed
from the workspace by another user
**Now**
- if a user has multiple workspaces, they are given the possibility to
leave one workspace while remaining in the others (we show two buttons:
Leave workspace and Delete account buttons). if a user has just one
workspace, they only see Delete account
- when a user leaves a workspace, we delete their workspaceMember,
userWorkspace and roleTarget. If they don't belong to any other
workspace we also soft-delete their user
- soft-deleted users get hard deleted after 30 days thanks to a cron
- we have two commands to clean the orphans roleTarget and userWorkspace
(TODO: query db to see how many must be run)
**Next**
- once the commands have been run, we can implement and introduce the
foreign key constraint on roleTarget
Fixes https://github.com/twentyhq/twenty/issues/14608
As title
- adds decorators in twenty-sdk
- update twenty-cli load-manifest to it gets @FieldMetadata infos +
testing
- update twenty-server so it CRUD fields properly, using
universalIdentifier
- Fix UI so we can update managed objects records
- move FieldMetadata items from twenty-server to twenty-shared
Here is what the PR does:
- Surface password state in validatePasswordResetToken, returning
hasPassword so the client can tell whether a user is setting or changing
their password.
- Consume that flag throughout the front end (mock data, stories,
GraphQL types) and update the Reset/Set Password modal to swap the
heading/button label and success toast accordingly.
- After a successful password set/reset, immediately update the
logged-in user’s hasPassword flag so the Settings screen reflects the
new state without a reload.
Modal has two states now - reset password modal uses change password
state since it made intuitive sense.
<p align="center">
<img width="404" height="397" alt="image"
src="https://github.com/user-attachments/assets/c54cc581-1248-4395-833d-0202758e1947"
/>
</p>
<p align="center">
<img width="403" height="393" alt="image"
src="https://github.com/user-attachments/assets/d8a39a95-27e6-4037-86f2-1f74176002ba"
/>
</p>
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
groupMode should be undefined if no groupby is set on the secondary
axis!
This default value is also the reason -- all the logic -- where
groupMode was checked for conditional rendering -- for eg
, negative data labels when there is no groupby -- the labels should
appear below wasn't happening -- since there was always a default to
groupMode!
changes -
- getting rid of the default value on the bar chart DTO for groupMode
- on front -- make sure groupMode is only set when the secondary axis
gets groupBy id and proper cleanup
Prevent users from creating v2 chart types via the api.
Only created unit tests and not integration tests (since it's not that
important, and the v2 will be released soon), but tested via the api
playground.