## Summary
- **Optimistic metadata store updates**: Replace `refetchQueries` with
direct `addToDraft`/`applyChanges` calls in create, update, and delete
navigation menu item mutation hooks for instant UI feedback. Client-side
UUID generation enables optimistic creates before the server responds.
- **SSE event enrichment with `targetRecordIdentifier`**: Introduce
`NavigationMenuItemRecordIdentifierService` to resolve record display
info (label, image) and enrich SSE metadata events at emission time, so
the sidebar shows record names immediately without a page refresh.
- **Centralized role permission resolution**: Add
`resolveRolePermissionConfigFromAuthContext` to `PermissionsService`,
removing duplicated role resolution logic from individual services.
- **Mutation fragments include `targetRecordIdentifier`**: Switch
create/update/delete mutations from `NavigationMenuItemFields` to
`NavigationMenuItemQueryFields` so the mutation response includes
`targetRecordIdentifier`, preventing a brief gap where RECORD favorites
are invisible in the sidebar.
- **Folder UI fixes**: Remove transparent border on
`StyledFolderContainer` that caused a 1px size inconsistency between
folder and non-folder items in Favorites. Make the folder kebab menu
hover-only instead of always visible.
- enrich SSE events with relations
- remove queries from sse metadata events
- on sse event, manage store
- on object/field metadata changes, manage store
TODO left:
- fix other metadata items
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
### What
Unifies record page layout editing and navigation menu editing into a
single global "layout customization" session. Dashboard editing stays
separate.
### How it works
**Two edit mode systems, one context-based read:**
- `isLayoutCustomizationModeEnabledState` -- global atom for record
pages + navigation
- `isDashboardInEditModeComponentState` -- dashboard-only, independent
per-component atom
- `PageLayoutEditModeProvider` -- context that dispatches to
`RecordPageLayoutEditModeProvider` (reads global atom) or
`DashboardPageLayoutEditModeProvider` (reads component atom), one
component per file
**Session registry + independent atoms:**
- `activeCustomizationPageLayoutIdsState` -- accumulates page layout IDs
as user navigates during customization (`string[]`)
- Save/cancel iterate the ID list and read each layout's draft/persisted
atoms independently
- Follows the same pattern as `settingsRoleIdsState` +
`settingsDraftRoleFamilyState`
**Unified UI:**
- `LayoutCustomizationBar` replaces the old `NavigationMenuEditModeBar`
- Enter once -- edit record layouts + navigation -- save/cancel
everything together
- `useSaveLayoutCustomization` orchestrates sequential save: navigation
draft -- page layouts -- field widget groups
- Error snackbar on partial save failure (with TODO for future atomic
server mutation)
**Draft protection during customization:**
- `PageLayoutRelationWidgetsSyncEffect` guarded -- only updates
persisted state from server, skips draft/currentLayouts while
customization is active
- `useExecuteTasksOnAnyLocationChange` skips draft reset when
customization mode is enabled
- Command execution blocked during layout customization
### Cleanup
- Deleted `NavigationMenuEditModeBar`,
`isNavigationMenuInEditModeState`,
`isPageLayoutInEditModeComponentState`,
`useIsGlobalLayoutCustomizationActive`
- `DraftPageLayout` type changed from `Omit` to `Pick` (explicit fields)
- Removed save/cancel from `DefaultRecordCommandMenuItemsConfig` (bar
handles it now)
- Extracted `useSaveFieldsWidgetGroups` from save orchestration
- Split `PageLayoutEditModeProvider` into 3 separate files (one
component per file, Twenty convention)
### Known issues
- **Stale deleted widget after save (pre-existing on `main`)**: Delete
widget -- save -- exit customization -- Apollo cache stale -- sync
effect overwrites Jotai from stale data -- widget reappears until
refresh. Separate PR needed, likely tied to the planned server-side
`saveLayoutCustomization` atomic endpoint.
### Open questions
- **Module location**: Layout customization hooks/states live in `/app`
-- should they move to their own `modules/layout-customization/`?
- **Atomic server mutation**: All save mutations are on metadata schema
(`createNavigationMenuItem`, `deleteNavigationMenuItem`,
`updateNavigationMenuItem`, `updatePageLayoutWithTabsAndWidgets`,
`upsertFieldsWidget`). A single `saveLayoutCustomization` endpoint could
make saves truly atomic.
https://github.com/user-attachments/assets/036ef542-97f3-485b-a68f-3726002c81fb
## Summary
- Adds a `color` column to `ObjectMetadataEntity` with full GraphQL
support so object icon colors are persisted at the metadata level
- Adds a `type` column to `NavigationMenuItemEntity` (enum: `OBJECT`,
`VIEW`, `FOLDER`, `LINK`, `RECORD`) replacing field-based type inference
- Updates frontend to read object colors from `objectMetadata.color`
(falling back to standard defaults) in the sidebar nav, record index
header, and record show breadcrumb
- Simplifies `NavigationMenuItemIcon` color resolution via
`getEffectiveNavigationMenuItemColor` util
## Color rules
| Item type | Color source | Editable in sidebar? |
|-----------|-------------|---------------------|
| **Object** | `objectMetadata.color` | Yes — persisted to
`objectMetadata.color` on Save |
| **Folder** | `navigationMenuItem.color` | Yes |
| **Link** | Fixed default (`DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK`) |
No |
| **View** | `objectMetadata.color` (from the parent object) | No |
| **Record** | None | No |
- **Object** items represent the whole object (e.g. "Companies") and
point to the INDEX view. Changing their color updates
`objectMetadata.color` via `useSaveObjectMetadataColorsFromDraft`.
- **View** items represent specific non-INDEX views. Their color comes
from the parent object's metadata (read-only).
- Only **folders** store their color on `navigationMenuItem.color` —
enforced by `hasNavigationMenuItemOwnColor` util.
- `getEffectiveNavigationMenuItemColor` returns `objectColor` for both
OBJECT and VIEW items, folder's own color for folders, and the fixed
default for links.
## NavigationMenuItemType enum
- Shared enum created in `twenty-shared` with values: `OBJECT`, `VIEW`,
`FOLDER`, `LINK`, `RECORD`
- Registered as a GraphQL enum on the backend
- Replaces string literals across entity, DTOs, input, converters, and
frontend hooks
- Migration backfills existing rows: INDEX views → `OBJECT`, non-INDEX
views → `VIEW`, based on join with the view table
## Design decisions
- **OBJECT vs VIEW distinction**: Items pointing to INDEX views are
typed as `OBJECT` (represent the whole object, color editable). Items
pointing to non-INDEX views are typed as `VIEW` (specific view, color
read-only from parent object).
- **Dual color storage**: `navigationMenuItem.color` is preserved for
folders only. Objects use `objectMetadata.color` as their source of
truth.
- **Type discriminator**: The `type` column replaces field-based
inference (checking `viewId`, `link`, `targetRecordId` presence) with an
explicit enum, simplifying `isNavigationMenuItemLink` /
`isNavigationMenuItemFolder` to simple `item.type ===` checks.
- **No settings page color picker**: Object color editing is done from
the sidebar edit panel, not the data model settings page.
## Test plan
- [ ] Verify objects display their default standard colors in the
sidebar
- [ ] Verify object color editing works in the sidebar edit panel
(persists to objectMetadata.color)
- [ ] Verify folder color editing works in the sidebar edit panel
- [ ] Verify views, links, and records do NOT show a color picker in the
sidebar edit panel
- [ ] Run `npx nx typecheck twenty-front` and `npx nx typecheck
twenty-server`
- [ ] Verify the database migrations add `color` to `objectMetadata` and
`type` to `navigationMenuItem`
Made with [Cursor](https://cursor.com)
## Summary
Closes#18673
Some languages (e.g., German "Unternehmen") and even English words
(sheep, deer, aircraft, series) have identical singular and plural
forms. Twenty previously blocked saving when labels matched, making it
impossible to correctly name objects in these cases.
- **Labels** are purely display strings — removed the equality
validation from both the frontend Zod schema and backend validator
- **API names** (nameSingular/namePlural) must stay different since they
generate distinct GraphQL resolvers (`findOne` vs `findMany`,
`createOne` vs `createMany`, etc.) and REST endpoints — this validation
is preserved
- Added a shared `computeMetadataNamesFromLabels` util in
`twenty-shared` that auto-appends `'s'` to the plural API name when both
labels produce the same camelCase name (e.g., "Unternehmen" →
`unternehmen` / `unternehmens`)
- Both the frontend form and backend sync-check use the same shared util
— single source of truth, no duplicated logic
**No retroactive impact**: since the old code prevented identical labels
from ever being saved, no existing workspace has `labelSingular ===
labelPlural`.
## Test plan
- [x] New unit tests for `computeMetadataNamesFromLabels` (7 tests:
standard labels, Sheep, Unternehmen, Aircraft, empty labels, different
labels, applyCustomSuffix)
- [x] Updated frontend schema validation tests (identical labels with
different names now passes; identical names still fails)
- [x] Updated backend integration test cases (removed identical-label
failing cases)
- [ ] Manual: create a new object with identical singular/plural labels
(e.g. "Sheep" / "Sheep") — should save successfully with API names
`sheep` / `sheeps`
- [ ] Manual: verify existing objects with different labels still work
unchanged
Made with [Cursor](https://cursor.com)
## Fix: Standard object rename ignored when UI language is not English
(Closes#18650)
### Problem
When a user renames a standard object (for example, changing **"People"
→ "Contacts"**), the custom name is only respected when the UI language
is set to **English**.
For any other locale, the resolver ignores the user-defined override and
falls back to the i18n translation of the original default label.
As a result, the custom name defined by the user is not displayed when
the UI language changes.
### Root Cause
`resolveObjectMetadataStandardOverride` only applied direct overrides
when the locale matched `SOURCE_LOCALE` (English):
```ts
if (
safeLocale === SOURCE_LOCALE &&
isNonEmptyString(objectMetadata.standardOverrides?.[labelKey])
) {
return objectMetadata.standardOverrides[labelKey] ?? '';
}
## Summary
- **Remove all "core" prefixes** from the views system — the
metadata-based storage migration is complete, so `CoreView`,
`coreViewsSelector`, `getCoreViews`, etc. are now just `View`,
`viewsSelector`, `getViews`
- **Eliminate the entire converter layer** (15 files, ~850 lines
deleted) — `convertCoreViewToView` and all sub-converters were either
no-ops or trivially adding `__typename` / mapping identical enum values.
Local enums now re-export from generated GraphQL types directly (single
source of truth)
- **Unify `View` and `ViewWithRelations`** into one type —
`ViewWithRelations` is now a type alias for `View`, selectors return
data directly without conversion
### Backend
- Rename `@ObjectType('CoreView')` → `@ObjectType('View')` (and all
sub-entities)
- Rename resolver methods: `getCoreViews` → `getViews`, `createCoreView`
→ `createView`, etc.
- Rename `FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION` →
`FIND_ALL_VIEWS_GRAPHQL_OPERATION`
### Frontend
- Delete 15 converter files (`convertGqlView*ToView*`,
`convertView*ToGql`, `convertViewWithRelationsToView`)
- Re-export `ViewType`, `ViewKey`, `ViewFilterGroupLogicalOperator` from
generated enums (no more duplicate enum definitions with different
casing)
- Replace `ViewOpenRecordInType` with `ViewOpenRecordIn` from generated
- Remove `__typename` from all local view sub-types
- Remove unused `variant` from `ViewFilter`, make `displayValue` and
`definition` optional
- Rename ~45 GraphQL query/mutation files and all selectors to drop
"core" prefix
- Delete unused `viewsWithRelationsSelector`
## Summary
Replace the single `metadataVersion` integer with per-entity-type
**collection hashes** for granular metadata staleness detection. The
backend already generates a UUID per flat entity map on each cache
recompute (`crypto.randomUUID()` in `WorkspaceCacheService`); we now
expose these via the minimal metadata endpoint and SSE events so the
frontend can compare and know exactly which entity types are stale.
### Key changes
**Backend:**
- `WorkspaceCacheService.getCacheHashes()` — new public method that
reads only `:hash` keys from Redis without fetching full data
- `MinimalMetadataDTO` — added `collectionHashes: Record<string,
string>` (JSON scalar mapping `AllMetadataName` → collection hash),
removed `metadataVersion`
- `MetadataEventDTO` — added optional `updatedCollectionHash` field to
SSE events
- `MetadataEventsToDbListener` — reads the collection hash for the
affected entity type after cache invalidation and attaches it to the SSE
event before publishing
- `MinimalMetadataService` — no longer queries the workspace table; uses
`getCacheHashes()` for all flat entity maps and maps cache keys to
`AllMetadataName` locally
**Frontend:**
- `metadataCollectionHashesState` — new Jotai atom with
`atomWithStorage` + `getOnInit: true` storing
`Partial<Record<MetadataEntityKey, string>>`
- `mapAllMetadataNameToEntityKey()` — explicit mapping from backend
`AllMetadataName` to frontend `MetadataEntityKey` (23 entries)
- `useLoadMinimalMetadata` — stores `collectionHashes` from server,
computes `staleEntityKeys` by comparing local vs server hashes
- `patchMetadataStoreFromSSEEvent()` — accepts optional
`updatedCollectionHash` and updates `metadataCollectionHashesState`
- All 11 SSE effect components — pass
`eventDetail.updatedCollectionHash` through to the patch function
- `useStaleMetadataEntities` — new hook returning entity keys missing
from collection hashes (not yet loaded/synced)
- `resetMetadataStore()` — also clears collection hashes
- Deleted `metadataVersionState` (superseded by collection hashes)
### Design decisions
- **No change to hash generation** — existing `crypto.randomUUID()` is
sufficient. Hashes are persisted in Redis, survive server restarts, and
change only on `invalidateAndRecompute`.
- **"Collection hash" naming** — used consistently to clarify the hash
represents an entire entity collection (e.g., all views), not a single
record.
- **Mapping localized** — backend `WorkspaceCacheKeyName` →
`AllMetadataName` mapping lives in the minimal metadata service.
Frontend `AllMetadataName` → `MetadataEntityKey` mapping lives in a
local utility. Nothing in `twenty-shared`.
- **Backward compatible** — `collectionHashes` is additive;
`updatedCollectionHash` is nullable.
## Summary
Uniformizes the metadata store to support **all** backend flat metadata
types, introduces a **minimal metadata endpoint** for fast initial
renders, replaces custom localStorage persistence with **Jotai's
built-in `atomWithStorage`**, and wires up a
**MinimalMetadataLoadEffect** for stale-while-revalidate loading.
### Key changes
- **All flat metadata types**: Added `FlatCommandMenuItem`,
`FlatFrontComponent`, `FlatWebhook`, `FlatRole`, `FlatRoleTarget`,
`FlatAgent`, `FlatSkill`, `FlatRowLevelPermissionPredicate`,
`FlatRowLevelPermissionPredicateGroup` — every entity in the backend
`MetadataEntityTypeMap` now has a corresponding frontend flat type
registered in `ALL_METADATA_ENTITY_KEYS` and `MetadataEntityTypeMap`.
- **Minimal metadata endpoint** (`minimalMetadata` GraphQL query): New
backend module (`MinimalMetadataModule`) returns lightweight object
metadata (names, icons, labels, flags) and basic views (id, type, key,
objectMetadataId) plus a `metadataVersion`. This enables fast first
paint before full metadata loads.
- **Jotai `atomWithStorage` for persistence**: Replaced the custom
`MetadataLocalStorageEffect` with Jotai's built-in `atomWithStorage` on
both `metadataStoreState` (family) and `metadataVersionState`. Added
`localStorageOptions` support to `createAtomFamilyState` for `{
getOnInit: true }` synchronous hydration. Each entity atom auto-persists
under keys like `metadataStoreState__objectMetadataItems`.
- **MinimalMetadataLoadEffect**: New effect mounted before
`MetadataProviderInitialEffects` that checks if the store already has
data (from Jotai localStorage hydration). If empty, it fetches minimal
metadata from the new endpoint. The full metadata load continues in
parallel, eventually enriching the store with complete data.
- **SSE effects alignment**: All metadata entity types now have
corresponding SSE effects that directly patch the metadata store via
`patchMetadataStoreFromSSEEvent`.
- **Existing selectors and joining logic**:
`objectMetadataItemsWithFieldsSelector`, `viewsWithRelationsSelector`,
`pageLayoutsWithRelationsSelector` reconstruct nested data from flat
entities for components that need it.
### Loading flow
```
App mount
→ Jotai atomWithStorage hydrates store from localStorage (sync, getOnInit)
→ MinimalMetadataLoadEffect
→ Store has data? → skip (app renders immediately)
→ Store empty? → fetch minimalMetadata endpoint → populate objects + views
→ MetadataProviderInitialEffects (full metadata load, runs in parallel)
→ LazyMetadataLoadEffect (page layouts, logic functions, nav menu, etc.)
→ IsAppMetadataReadyEffect (sets isAppMetadataReady)
```
## Test plan
- [ ] Verify app loads with empty localStorage (should fetch minimal
metadata, then full)
- [ ] Verify app loads with populated localStorage (should skip minimal
fetch, render immediately)
- [ ] Verify SSE events correctly update metadata store for all entity
types
- [ ] Verify logout clears metadata store (atom reset propagates to
localStorage)
- [ ] Verify all metadata selectors return correct joined data
- [ ] CI: lint, typecheck, tests pass
## Context
This PR introduces overrides for view fields which will be useful for
page layout FIELDS widgets fields position/groups/visibility override +
restore logic.
## Summary
- **Removes the entire `modules/favorites/` directory** (~66 files,
~5000 lines deleted) — components, hooks, states, types, utils, tests,
and the favorite-folder-picker sub-module
- **Eliminates the dual-write pattern** where creating a favorite also
created a NavigationMenuItem — all consumers now use
`useCreateNavigationMenuItem` directly
- **Removes `IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED` feature flag
checks** from ~12 files, always taking the NavigationMenuItem code path
- **Cleans up backend dual-writes** in `object-metadata.service.ts` and
`twenty-standard-application.service.ts` that were creating Favorite
records alongside NavigationMenuItems
- **Updates prefetch system** to only load NavigationMenuItems (removes
favorites prefetch effects and states)
- **Cleans up test infrastructure** — updates Storybook decorators, mock
data, and graphql mocks to remove favorites references
### What was intentionally kept
- **Backend entity definitions** (`FavoriteWorkspaceEntity`,
`FavoriteFolderWorkspaceEntity`) — these define the database schema and
need a proper database migration to remove
- **Cascade deletion listeners** — still needed to clean up existing
Favorite data in workspaces that haven't been fully migrated
- **v1.18 migration commands** — needed for workspaces upgrading from
older versions
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- Renames the `FieldMetadataType` enum key from `RICH_TEXT_V2` to
`RICH_TEXT` across the entire codebase, while keeping the underlying
string value as `'RICH_TEXT_V2'` to maintain PostgreSQL database
compatibility
- Renames all related types, guards, hooks, components, and files from
`*RichTextV2*` / `*rich-text-v2*` to `*RichText*` / `*rich-text*` (e.g.
`FormRichTextV2FieldInput` → `FormRichTextFieldInput`,
`isFieldRichTextV2` → `isFieldRichText`)
- Updates generated files (GraphQL schema, SDK types) to use the new key
while preserving the `RICH_TEXT_V2` string value for DB/API layer
- Updates i18n locale files, test snapshots, and integration tests to
reflect the rename
## Context
The legacy `RICH_TEXT` (V1) field type was deprecated and migrated to
`TEXT` in a previous PR (#18623). With V1 gone, the `RICH_TEXT_V2`
naming is no longer necessary — `RICH_TEXT` is now the canonical name.
The DB enum value stays `'RICH_TEXT_V2'` to avoid confusion with the
just-deprecated V1 type and to prevent a database migration.
## Test plan
- [x] `twenty-server` typecheck passes
- [x] `twenty-front` typecheck passes (only pre-existing Apollo client
errors remain)
- [x] `twenty-server` lint passes
- [x] `twenty-front` lint passes
- [x] `twenty-shared` build passes
- [ ] CI passes
Made with [Cursor](https://cursor.com)
## Summary
- Removes the deprecated `RICH_TEXT` (V1) field metadata type from the
codebase entirely
- Adds a 1.20 upgrade command that migrates existing `RICH_TEXT` fields
to `TEXT` in `core.fieldMetadata`
- Cleans up ~70 files across `twenty-shared`, `twenty-server`,
`twenty-front`, `twenty-sdk`, and `twenty-zapier`
## Context
`RICH_TEXT` was a legacy field type that stored rich text as a single
`text` column. It was already **read-only** — writes threw errors
directing users to `RICH_TEXT_V2` instead. `RICH_TEXT_V2` is the current
approach: a composite type with `blocknote` (editor JSON) and `markdown`
subfields. Keeping the deprecated type added maintenance burden without
any value.
Since the underlying database column type for `RICH_TEXT` was already
`text` (same as `TEXT`), the migration only needs to update the metadata
— no data migration or column changes required.
## Changes
### Upgrade command (new)
- `1-20-migrate-rich-text-to-text.command.ts` — runs `UPDATE
core."fieldMetadata" SET "type" = 'TEXT' WHERE "type" = 'RICH_TEXT'` per
workspace, with cache invalidation
### Enum & shared types
- Removed `RICH_TEXT` from `FieldMetadataType` enum
- Removed from `FieldMetadataDefaultValueMapping`,
`isFieldMetadataTextKind`
### Server (~30 files)
- Removed from type mapper (scalar, filter, order-by), data processors,
input transformer, filter operators, zod schemas, column type mapping,
searchable fields, RLS matching, OpenAPI schema, fake value generators
- Removed from field creation flow and field metadata type validator
- Updated dev seeder Pet `bio` field to `TEXT`
- Cleaned up mocks, snapshots, integration tests
### Frontend (~25 files)
- Deleted: `RichTextFieldDisplay`, `isFieldRichText`,
`isFieldRichTextValue`, `useRichTextFieldDisplay`
- Removed from `FieldDisplay`, `usePersistField`, `isFieldValueEmpty`,
`isRecordMatchingFilter`, `generateEmptyFieldValue`,
`isFieldCellSupported`, spreadsheet import, workflow fake values
- Removed from settings types, field type configs, and field creation
exclusion list
- Updated tests, mocks, and stories
### SDK & Zapier
- Removed from generated GraphQL schema and TypeScript types
- Removed from Zapier `computeInputFields`
## Summary
- Adds `gemini-3.1-flash-lite-preview` to the Google AI models registry
- Ultra-low-cost Gemini model ($0.25/M input, $1.50/M output) — half the
price of Gemini 3 Flash
- 1M context window, 64K max output, supports dynamic thinking
- No service code changes needed — the existing `AiModelRegistryService`
auto-discovers models from constants
## Changes
- `ai-models-types.const.ts`: Added `gemini-3.1-flash-lite-preview` to
the `ModelId` type union
- `google-models.const.ts`: Added model configuration with pricing,
context window, and capabilities
## Test plan
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint twenty-server` passes
- [ ] With `GOOGLE_API_KEY` set, model appears in available models list
- [ ] Existing Gemini models unaffected
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Context
Improve view resolution using cache and dataloader
## Performance Comparison
|Run|Main (no DataLoaders/cache)|Feature Branch (DataLoaders +
cache)|Speedup|
|---|---|---|---|
|1 (cold)|418ms|95ms|~4.4x faster|
|2|42ms|19ms|~2.2x faster|
|3|37ms|19ms|~1.9x faster|
|4|39ms|12ms|~3.2x faster|
|5|33ms|13ms|~2.5x faster|
The biggest improvement is to use dataloaders for the multiple relations
associated with views. Cache is a bit less significant since there are
other cache mechanism such as PostgreSQL buffer cache but it will
probably be more meaningful with bigger workspaces
## Summary
- Same fix as #18590 but applied to `FieldMetadataDTO`
- Changed `universalIdentifier` from `UUID` to `String` type since field
metadata universal identifiers are not necessarily valid UUIDs
- Removed `universalIdentifier` from `FieldFilter` (was using
`UUIDFilterComparison`)
- Updated generated SDK and frontend types accordingly
## Summary
Implements enterprise licensing and per-seat billing for self-hosted
environments, with Stripe as the single source of truth for subscription
data.
### Components
- **twenty-website** hosts the private key to sign `ENTERPRISE_KEY` and
`ENTERPRISE_VALIDITY_TOKEN`. It communicates with Stripe to emit the
daily `ENTERPRISE_VALIDITY_TOKEN` if the subscription is active, based
on the user's Stripe subscription ID stored in `ENTERPRISE_KEY`.
- **Stripe** is the single source of truth for subscription data
(status, seats, billing).
- **The client** (twenty-server + DB + workers) saves `ENTERPRISE_KEY`
in the `keyValuePair` table (or `.env` if
`IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false) and the daily-renewed
`ENTERPRISE_VALIDITY_TOKEN` in the `appToken` table.
`ENTERPRISE_VALIDITY_TOKEN` is verified client-side using a public key
to grant access to enterprise features (RLS, SSO, audit logs, etc.).
### Flow
1. When requesting an upgrade to an enterprise plan (from **Enterprise**
in settings), the user is shown a modal to choose monthly/yearly
billing, then redirected to Stripe to enter payment details. After
checkout, they land on twenty-website where they are exposed to their
`ENTERPRISE_KEY`, which they paste in the UI. It is saved in the
`keyValuePair` table. On activation, a first `ENTERPRISE_VALIDITY_TOKEN`
with 30-day validity is stored in the `appToken` table.
2. **Every day**, a cron job runs and does two things:
- **Refreshes the validity token**: communicates with twenty-website to
get a new `ENTERPRISE_VALIDITY_TOKEN` with 30-day validity if the Stripe
subscription is still active. If the subscription is in cancellation,
the emitted token has a validity equal to the cancellation date. If it's
no longer valid, the token is not replaced. The cron only needs to run
every 30 days in practice, but runs daily so it's resilient to
occasional failures.
- **Reports seat count**: counts active (non-soft-deleted)
`UserWorkspace` entries and sends the count to twenty-website, which
updates the Stripe subscription quantity with proration. Seats are also
reported on first activation. If the subscription is canceled or
scheduled for cancellation, the seat update is skipped.
3. `ENTERPRISE_VALIDITY_TOKEN` is verified server-side via a public key
to grant access to enterprise features.
### Key concepts
Three distinct checks are exposed as GraphQL fields on `Workspace`:
| Field | Meaning |
|---|---|
| `hasValidEnterpriseKey` | Has any valid enterprise key (signed JWT
**or** legacy plain string) |
| `hasValidSignedEnterpriseKey` | `ENTERPRISE_KEY` is a properly signed
JWT (billing portal makes sense) |
| `hasValidEnterpriseValidityToken` | `ENTERPRISE_VALIDITY_TOKEN` is
present and not expired (expiration depends on signed token payload, not
on "expiresAt" on appToken table which is only indicative) |
Feature access is gated by `isValid()` =
`hasValidEnterpriseValidityToken || hasValidEnterpriseKey` (to support
both new and legacy keys during transition). After transition isValid()
= hasValidEnterpriseValidityToken
### Frontend states
The Enterprise settings page handles multiple states:
- **No key**: show "Get Enterprise" with checkout modal
- **Orphaned validity token** (token valid but no signed key): prompt
user to set a valid enterprise key
- **Active/trialing but no validity token**: show subscription status
with a "Reload validity token" action
- **Active/trialing**: show full subscription info, billing portal
access, cancel option
- **Cancellation scheduled**: show cancellation date, billing portal
- **Canceled**: show billing history link and option to start a new
subscription
- **Past due / Incomplete**: prompt to update payment or restart
### Temporary retro-compatibility: legacy plain-text keys
Previously, enterprise features were gated by a simple check: any
non-empty string in `ENTERPRISE_KEY` granted access. With this PR, we
transition to a controlled system relying on signed JWTs.
To avoid breaking existing self-hosted users:
- **Legacy plain-text keys still grant access** to enterprise features.
`hasValidEnterpriseKey` returns `true` for both signed JWTs and plain
strings, and `isValid()` checks `hasValidEnterpriseKey` as a fallback
when no validity token is present.
- **A deprecation banner** is shown at the top of the app when
`hasValidEnterpriseKey` is `true` but `hasValidSignedEnterpriseKey` is
`false`, informing the user that their key format is deprecated and they
should activate a new signed key.
- **No billing portal or subscription management** is available for
legacy keys since there is no Stripe subscription to manage.
This retro-compatibility will be removed in a future version. At that
point, `isValid()` will only check `hasValidEnterpriseValidityToken`.
### Edge cases
- **Air-gapped / production environments**: for self-hosted clients that
block external traffic (or for our own production), provide a long-lived
`ENTERPRISE_VALIDITY_TOKEN` (e.g. 99 years) directly in the `appToken`
table, with no `ENTERPRISE_KEY`. The daily cron will skip the refresh
(no enterprise key to authenticate with), but the pre-seeded validity
token will be used to grant feature access. No billing or seat reporting
occurs in this mode.
- **`IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false**: if the user tries to
activate an enterprise key but DB config writes are disabled, the
backend returns a clear error asking them to add `ENTERPRISE_KEY` to
their `.env` file manually.
- **Canceled subscriptions**: the `/seats` endpoint skips Stripe updates
for canceled or cancellation-scheduled subscriptions to avoid Stripe API
errors.
### How to test
- launch twenty-website on a different url (eg localhost:1002)
- add ENTERPRISE_API_URL=http://localhost:3002/api/enterprise (or else)
in your server .env
- ask me for twenty-website's .env file content (STRIPE_SECRET_KEY;
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID;STRIPE_ENTERPRISE_YEARLY_PRICE_ID;
ENTERPRISE_JWT_PRIVATE_KEY; ENTERPRISE_JWT_PUBLIC_KEY;
NEXT_PUBLIC_WEBSITE_URL)
- visit Admin panel / enterprise
This PR fixes what allows to have a working demo workspace skill.
- Skill updated many times into something that works
- Fixed infinite loop in AI chat by memoizing ai-sdk output
- Finished navigateToView implementation
- Increased MAX_STEPS to 300 so the chat don't quit in the middle of a
long running skill
- Added CreateManyRelationFields
## PR Description
In the process of migrating all the existing commands to the backend, we
stumbled across a couple of problems that made us reconsider the full
migration. This PR introduces a way for command menu items to bypass
front components and to directly reference a frontend component from
twenty front.
It:
- Introduces a `engineFrontComponentKey` field on `CommandMenuItem` as
an alternative to `frontComponentId` and `workflowVersionId`, allowing
command menu items to reference frontend components by key directly
rather than requiring a FrontComponent entity
- Updates the DB constraint to allow exactly one of `workflowVersionId`,
`frontComponentId`, or `engineFrontComponentKey`
### All standard command menu items from the frontend which use
`standardFrontComponentKey`
These are all commands that execute a GraphQL query or a mutation.
Two mains concerned have been raised that made us go with this
(temporary) architecture instead:
- If those commands are part of the standard application, they can only
alter objects from that application and not custom objects.
- We would need to implement a way to trigger optimistic rendering from
the front components, which might take some time to implement.
List:
- Create new record
- Delete (single record)
- Delete records (multiple)
- Restore record
- Restore records (multiple)
- Permanently destroy record
- Permanently destroy records (multiple)
- Add to favorites
- Remove from favorites
- Merge records
- Duplicate Dashboard
- Save Dashboard
- Save Page Layout
- Activate Workflow
- Deactivate Workflow
- Discard Draft (workflow)
- Test Workflow
- Tidy up workflow
- Duplicate Workflow
- Stop (workflow run)
- Use as draft (workflow version)
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Add standard command menu items
### Summary
This PR introduces standard command menu items, migrating hardcoded
command menu actions to the backend command menu item architecture
powered by front components. It adds a new `twenty-standard-application`
package that defines, builds, and registers front components as standard
command menu items, gated behind the `IS_COMMAND_MENU_ITEM_ENABLED`
feature flag.
### Description
- **New `twenty-standard-application` package**: Contains front
component definitions with an esbuild-based build pipeline that
generates minified `.mjs` bundles and a manifest with checksums.
- **Server-side registration**: New constants register all items with
metadata (labels, icons, positions, availability types, conditional
expressions). A `StandardFrontComponentUploadService` uploads built
components to file storage.
- **`FALLBACK` availability type**: New enum value for command menu
items that appear as fallback options (e.g., "Search Records" fallback).
- **`CommandMenuContextApi` refactor**
- **Conditional availability enhancements**: New array-based helper
functions for evaluating multi-record conditions.
- **Frontend wiring** (twenty-front):
`useCommandMenuItemFrontComponentCommands`
## Next steps
Only simple commands have been implemented for now:
- **Navigation (9)** -- `CommandLink`: go-to-companies,
go-to-dashboards, go-to-notes, go-to-opportunities, go-to-people,
go-to-runs, go-to-settings, go-to-tasks, go-to-workflows
- **Side panel (4)** -- `CommandOpenSidePanelPage`: ask-ai,
search-records, search-records-fallback, view-previous-ai-chats
We still have to implement front components for all the following
commands:
All have placeholder `execute` logic (`async () => {}`) with a `// TODO:
implement execute logic` comment:
**Record (22)**
- `add-to-favorites`, `remove-from-favorites`
- `create-new-record`, `create-new-view`
- `delete-single-record`, `delete-multiple-records`
- `destroy-single-record`, `destroy-multiple-records`
- `restore-single-record`, `restore-multiple-records`
- `export-from-record-index`, `export-from-record-show`,
`export-multiple-records`, `export-note-to-pdf`, `export-view`
- `hide-deleted-records`, `see-deleted-records`
- `import-records`, `merge-multiple-records`, `update-multiple-records`
- `navigate-to-next-record`, `navigate-to-previous-record`
**Page layout (3)** -- `cancel-record-page-layout`,
`edit-record-page-layout`, `save-record-page-layout`
**Dashboard (4)** -- `cancel-dashboard-layout`, `duplicate-dashboard`,
`edit-dashboard-layout`, `save-dashboard-layout`
**Workflow (10)** -- `activate-workflow`, `add-node-workflow`,
`deactivate-workflow`, `discard-draft-workflow`, `duplicate-workflow`,
`see-active-version-workflow`, `see-runs-workflow`,
`see-versions-workflow`, `test-workflow`, `tidy-up-workflow`
**Workflow version (4)** -- `see-runs-workflow-version`,
`see-versions-workflow-version`, `see-workflow-workflow-version`,
`use-as-draft-workflow-version`
**Workflow run (3)** -- `see-version-workflow-run`,
`see-workflow-workflow-run`, `stop-workflow-run`
REST API allowed users to pass in targetOpportunity, targetPerson,
targetCompany etc when trying to create a noteTarget or a taskTarget.
The request went through, we got back a 201, the record was created, but
the relationship was never established since the FK was empty in the
database.
This PR enforces users to send in the property with the `Id` suffix for
consistency. So, the user sends in targetOpportunityId, targetPersonId,
targetCompanyId etc.
<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/ed50a623-68d4-4266-baf1-e94a657c3fd4"
/>
</p>
If the users try to send without the "Id" suffix, they get an error
explaining what to do.
<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/8bab399b-7a04-4b6a-86b5-6f0e0b1ecd5d"
/>
</p>
Additionally, the documentation itself contains the correct property
names.
<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/83e51cd6-8ef7-4a4d-8696-ab37cc4a9dd6"
/>
</p>
Finally, the filters also enforce this "Id" suffix convention in the GET
request.
<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/168a2f09-1242-40fa-bd84-1f7d9c60357c"
/>
</p>
Edit: Updated error messages after the screenshots were taken to make
them a little generic. Secondly, this PR also fixes the issue of morph
relation ids and objects not appearing in the response (when depth is
1).
## Summary
Front Before:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/b978f67c-c0a6-49fc-bedd-a443f11c365d"
/>
Front After:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/a4939dbb-a8b4-4c74-978c-daa7f27d00f3"
/>
Server Before:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/da53e97f-ec65-4224-a656-ca41040aef6e"
/>
Server After:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/8cdf3885-f515-4d6c-989f-a421a4e8206c"
/>
### CI Server Pipeline Restructuring
- Split monolithic `server-setup` job into three parallel jobs:
`server-build`, `server-lint-typecheck`, and `server-validation`
- `server-build` only handles build + Nx cache save (~1m vs old 3.5m),
unblocking downstream jobs faster
- `server-lint-typecheck` runs in parallel with no DB dependency
- `server-validation` handles DB setup, migration checks, and GraphQL
generation checks in parallel with tests
- Make `server-test` (unit tests) fully independent — no longer waits
for server-setup, builds its own artifacts
- Increase integration test shards from 8 to 10 for better parallelism
- Expected critical path reduction: ~10m → ~7m (~30% faster)
### CI Front Pipeline Improvements
- Use artifact upload/download for storybook build instead of rebuilding
in test shards
- Serve pre-built storybook via `http-server` in test jobs, with
`STORYBOOK_URL` env var
- Update `vitest.config.ts` to use `storybookUrl` when `STORYBOOK_URL`
is set
- Remove redundant `twenty-shared`, `twenty-ui`, `twenty-sdk` builds
from storybook test shards
### Vite Build Optimizations
- Conditionally enable `rollup-plugin-visualizer` behind `ANALYZE=true`
env var (not loaded by default)
- Broaden Istanbul coverage exclusions to skip test files, stories,
mocks, and decorators
- Remove `@tabler/icons-react` alias from twenty-front and storybook
configs
- Bundle `@tabler/icons-react` into twenty-ui instead of treating it as
an external dependency
- Add lazy loading with `React.lazy` + `Suspense` for all page-level
route components in `useCreateAppRouter`
## Summary
- **Module reorganization**: Moved `ApplicationUpgradeService` and cron
jobs to `application-upgrade/`, `ApplicationSyncService` to
`application-manifest/`, and
`runWorkspaceMigration`/`uninstallApplication` mutations to the manifest
resolver — each module now has a single clear responsibility.
- **Explicit install flow**: Removed implicit `ApplicationEntity`
creation from `ApplicationSyncService`. The install service and dev
resolver now explicitly create the `ApplicationEntity` before syncing.
npm packages are resolved at registration time to extract manifest
metadata (universalIdentifier, name, description, etc.), eliminating the
`reconcileUniversalIdentifier` hack.
- **Better error handling**: Frontend hooks now surface actual server
error messages in snackbars instead of swallowing them. Replaced the
ugly `ConfirmationModal` for transfer ownership with a proper form
modal. Fixed `SettingsAdminTableCard` row height overflow and corrected
the `yarn-engine` asset path.
## Test plan
- [ ] Register an npm package — verify manifest metadata (name,
description, universalIdentifier) is extracted correctly
- [ ] Install a registered npm app on a workspace — verify
ApplicationEntity is created and sync succeeds
- [ ] Test `app:dev` CLI flow — verify local app registration and sync
work
- [ ] Upload a tarball — verify registration and install flow
- [ ] Transfer ownership — verify the new modal UX works
- [ ] Verify error messages appear correctly in snackbars when
operations fail
Made with [Cursor](https://cursor.com)
# Introduction
Previously the auth jwt stragegy would lod the whole user entity in the
auth user context
On an exception it would completely get logged on the pods
## Security layer
- 0/ Updating the type system ( devxp only though )
- 1/ The jwt auth stragegy only load a specific sub set of the user
entity
- 2/ Sanitizing at the exception log level directly in case of a user
context
- 3/ Sanitizing at the console driver
The last two sanitization could sound a bit redundant though they're
still good fallback to keep in case new path occurs in the cb
This PR adds the necessary tool to create a demo workspace with :
relevant custom objects and fields, mock data and a real dashboard with
graph widgets.
It is still a bit under-optimized and slow but it works.
This PR also adds an AI tool that allows to see what happens in real
time, it navigates the app and waits when necessary.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
- Remove feature flag
- Remove legacy methods in file-upload and file-service
- Migrate AI Chat to new file management
---------
Co-authored-by: Charles Bochet <charles@twenty.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.
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
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>