## Customer-reported bug
A customer hit this when using the AI chat:
```json
{
"message": "API key 760d4822-da40-4b3f-9031-40563d7ed6c9 has no role assigned",
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"userFriendlyMessage": "This API key has no role assigned."
}
}
```
Their integration authenticates via API key. Somewhere along the way,
the role bound to that API key was deleted, leaving the API key
authenticated but role-less. Any request that hits a permission check
(`getRoleIdForApiKeyId`) blows up.
## Root cause
In `RoleService.deleteManyRoles`, the pre-deletion cleanup
(`assignDefaultRoleToMembersWithRoleToDelete`) only rebinds **user
workspaces** to the workspace default role. API keys and agents pointing
at the role are ignored. Because `RoleTargetEntity.role` declares
`onDelete: 'CASCADE'`, the FK then drops the role_target rows for those
API keys / agents — but the API keys themselves stay in `api_key`, now
orphaned in `apiKeyRoleMap`.
A previous read-side workaround
([2767ddac44](https://github.com/twentyhq/twenty/commit/2767ddac44) —
make the `role` ResolveField nullable) handled the API-key-details page,
but did not address the write paths (`getRoleIdForApiKeyId`).
## Fix
- Rename `assignDefaultRoleToMembersWithRoleToDelete` →
`rebindTargetsOfRoleToDeleteToDefaultRole` and extend it to rebind API
keys (via `ApiKeyRoleService.assignRoleToApiKey`) and agents (via
`AiAgentRoleService.assignRoleToAgent`) in the same step, before the
role is deleted.
- If the workspace default role doesn't satisfy `canBeAssignedToApiKeys`
/ `canBeAssignedToAgents`, the inner `assignRoleTo*` validation throws.
We catch that and rethrow as a `PermissionsException` with a
role-deletion-context message and two new codes —
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` /
`ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS` — so the admin sees a clear
"reassign these first" prompt rather than a confusing inner error.
## Scope / non-goals
- **Already-orphaned API keys are not auto-healed.** The customer still
needs to reassign a role to their existing orphan API key via the UI
(Settings > API Keys > [the key] > role). A separate cleanup command for
existing orphans is a follow-up.
- I did not investigate *why* the customer's session was authenticated
via API key in the AI chat — that may be their integration setup. Worth
confirming with them separately.
## Test plan
- [ ] Workspace with default role `Admin` (which has
`canBeAssignedToApiKeys: true`): create an API key with a custom role,
delete the custom role → API key is rebound to Admin, requests keep
working.
- [ ] Workspace with default role `Member` (default, has
`canBeAssignedToApiKeys: false`): create an API key with a custom role,
delete the custom role → role deletion fails with the new
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` error explaining the admin must
reassign first. API key + custom role are both unchanged.
- [ ] Same two scenarios for agents (`canBeAssignedToAgents`).
- [ ] Existing user-workspace rebind behavior is unchanged.
- [ ] Role deletion with no dependent API keys / agents still works.
## Summary
- Fixes https://github.com/twentyhq/twenty/issues/20906
- Moves `markStepForRecomputation` from `useUpdateStep` into the
lower-level `useUpdateWorkflowVersionStep` hook, so **every** caller
that updates a step also triggers output schema recomputation.
- Previously, renaming a step via the side panel title input called
`useUpdateWorkflowVersionStep` directly (bypassing `useUpdateStep`), so
the variable picker kept showing the initial default name (e.g. "Create
Record") instead of the user's custom name.
## Test plan
- [x] Rename a workflow step via the side panel title input
- [x] Verify the variable picker dropdown shows the updated name
- [x] Verify variable tags/chips in subsequent steps reflect the updated
name
- [x] Verify that updating step settings (e.g. changing object type)
still refreshes the output schema correctly
## Context
Adds the SDK plumbing for apps to declare custom permission flags and
the server-side manifest pipeline to persist them.
```typescript
import { definePermissionFlag } from 'twenty-sdk/define';
export const MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER = '…';
export default definePermissionFlag({
universalIdentifier: MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
key: 'MANAGE_INVOICES',
label: 'Manage Invoices',
description: 'Create, edit, and delete invoices',
icon: 'IconReceipt',
});
```
```typescript
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
import { MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER } from './permission-flags/manage-invoices';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
// ...
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
],
});
```
The flag can then be referenced by UUID in a role's
permissionFlagUniversalIdentifiers. On sync, the catalog row lands in
core.permissionFlag and the link in core.rolePermissionFlag.
## Not in this PR
- Runtime permission checks.
PermissionsService.getUserWorkspacePermissions still builds its result
from Object.values(PermissionFlagType), so custom flags are stored but
not yet enforced, code asking "does this role have MANAGE_INVOICES?"
won't get a meaningful answer. Widening PermissionsService and
UserWorkspacePermissions.permissionFlags to support arbitrary flag keys
is the next PR.
- PermissionFlag from apps can only define "tool" permissions and not
"settings" as a permissionType, this parameter is not mutable. This is
because "settings" are for settings page (until we might decide to
separate both type of permissions into 2 different entities) and apps
can't declare settings page or interact with them so this parameter
would be unnecessary.
## Summary
Fixes#19634
### Root Cause
The ECMAScript spec treats date-only strings (`YYYY-MM-DD`) as **UTC
midnight** when passed to `new Date()`. But `date-fns` comparison
functions (`isToday`, `isYesterday`, `isTomorrow`) operate in **local
time**. For users in UTC-negative timezones, UTC midnight April 14 is
April 13 evening locally — so the label shows "Yesterday" instead of
"Today".
### Fix
In `formatDateISOStringToRelativeDate.ts`, detect date-only strings
(length === 10) and append `T00:00:00` (no `Z`) to force local-time
parsing:
```ts
// Before
const targetDate = new Date(isoDate);
// After
const targetDate =
isoDate.length === 10 ? new Date(isoDate + 'T00:00:00') : new Date(isoDate);
```
Full datetime strings (with time component) are left unchanged — they
already carry timezone information.
### Tests
Added `formatDateISOStringToRelativeDate.test.ts` covering:
- `Today` / `Yesterday` / `Tomorrow` labels for date-only strings
- Regression case: date-only string parsed at local midnight (not UTC
midnight)
- Full datetime strings continue to work as before
## Before / After
| Scenario | Before | After |
|---|---|---|
| `"2026-04-14"` viewed at UTC-5 on April 14 | Yesterday ❌ | Today ✓ |
| `"2026-04-14"` viewed at UTC+0 on April 14 | Today ✓ | Today ✓ |
| `"2026-04-14T12:00:00Z"` | Today ✓ | Today ✓ |
---------
Co-authored-by: Marie Stoppa <marie@twenty.com>
## Summary
Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).
A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).
## What ships
### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
- BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.
### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.
### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.
### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.
### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.
### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.
### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.
## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
Standalone-page tabs with `layoutMode: CANVAS` were silently rendering
as GRID (border, padding, scroll). Now they render full-bleed, matching
the CANVAS contract elsewhere.
Three layered fixes:
- `getTabLayoutMode`: respect `tab.layoutMode` for `STANDALONE_PAGE`
(was hardcoded to GRID for any non-`RECORD_PAGE`)
- `getWidgetCardVariant`: CANVAS now wins regardless of page type —
refactored to early-return + exhaustive switch on `pageLayoutType`
- `FrontComponentWidgetRenderer`: removed hardcoded `overflow: auto`
(workflow/tasks/timeline widgets don't have it either)
New `getTabLayoutMode.test.ts`. Variant tests refactored to declarative
+ parameterized.
QA:
<img width="3024" height="1654" alt="CleanShot 2026-05-22 at 20 46
50@2x"
src="https://github.com/user-attachments/assets/cc61d459-6bc6-48de-ac79-d63a2ccd8957"
/>
https://github.com/user-attachments/assets/a3374e18-ad1b-4888-ab2b-d07730edccac
Follow-up to #20525, picks up the clipboard + mouse/pointer events asks
from the "Allow to copy to clipboard in front-component" Slack thread.
`navigator.geolocation` and `getBoundingClientRect` are intentionally
out of scope until we have a permission model.
### `copyToClipboard` host API
New SDK function `copyToClipboard` (in `twenty-sdk/front-component`)
that goes through the host bridge to `useCopyToClipboard` in
`twenty-front`:
```ts
import { copyToClipboard } from 'twenty-sdk/front-component';
await copyToClipboard('hello');
```
Host-side hardening (front-component code is untrusted):
- Drops anything that isn't a non-empty string
- Caps payload at 64KB
- Throttles to 1 call/sec per front-component instance
- Snackbar shows a truncated preview so the user can spot a mismatch
between the affordance they clicked and what actually got copied
### `mousemove` and pointer events
Added to `COMMON_HTML_EVENTS` (and the React mapping) so they fire on
every HTML tag the renderer ships: `mousemove`, `pointerdown/up/move`,
`pointerover/out/enter/leave/cancel`. Generator rerun for
`remote-elements.ts` and `remote-components.ts`.
`SerializedEventData` now also forwards pointer geometry: `pointerId`,
`pointerType`, `pressure`, `tangentialPressure`, `tiltX/Y`, `twist`,
`width/height`, `isPrimary`. Existing positional fields are unchanged.
### Coverage
- New Storybook stories: `HostApi/CopyToClipboard` and
`HtmlTag/Grouping/Div/Events::PointerMove`
- `useFrontComponentExecutionContext` unit tests cover the API call,
preview truncation, type guard, length cap, and rate limit
- Renderer Storybook suite 227 → 229, prebuild bundle count 219 → 221
# Introduction
Removing old standard objects `messageChannel` and `messageFolder` and
`calendarChannel`
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
Tested ↓
<img width="3456" height="1990" alt="CleanShot 2026-05-20 at 21 14
04@2x"
src="https://github.com/user-attachments/assets/b4e0d3d3-715f-4ad7-bd03-e8e1922b3c6c"
/>
- Enable `FieldDisplayMode.EDITOR` for plain `TEXT` field widgets while
keeping `FIELD` as the default display mode.
- Add a plain multiline text editor renderer for `TEXT + EDITOR` field
widgets with optimistic record-store/cache updates and debounced
persistence.
- Reuse the shared `TextArea` component through a transparent, uncapped
variant so the editor has no input chrome and lets the widget grow.
- Add unit coverage for text display-mode config and a Storybook
scenario for a text field widget in editor mode.
## useEffect cleanup note
`FieldWidgetTextEditor` flushes the debounced persist callback in a
`useEffect` cleanup:
```ts
useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]);
```
This follows the existing debounced autosave cleanup pattern already
used in `WorkflowEditActionHttpRequest`. It ensures pending text changes
are persisted when the widget unmounts, while `onBlur` still flushes
immediately for normal editor exits.
## Validation
- `npx nx test twenty-front --testPathPattern=page-layout`
- `npx nx typecheck twenty-front`
- `npx nx lint twenty-front`
- Browser check on
`http://apple.localhost:3001/object/company/20202020-a305-41e7-8c72-ba44072a4c58`
for transparent textarea, no internal max-height/scroll, equal padding,
and widget growth.
Note: lint passes with two unrelated existing warnings in
`NavigationDrawerItem.tsx` and `ConfigVariableEdit.tsx`.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
- Adds `IS_EMPTY` and `IS_NOT_EMPTY` operands to the UUID entry in
`getStepFilterOperands`, aligning the workflow filter action with the
find records (search) action which already includes these operands for
ID-type fields.
## Test plan
- [ ] Open a workflow with a filter action, select an ID-type field, and
verify the operand dropdown now includes "Is empty" and "Is not empty"
- [ ] Open a workflow with a find records action, select an ID-type
field, and verify the operand dropdown is consistent with the filter
action
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
Move the browsing context out of the system prompt and injecting it
directly into the last user message instead.
Previously (before this PR), browsing context change, update system
prompt then break whole conversation history ... and caching. Now,
browsing context is sent with last message only if changed.
"Benchmark" this PR vs main :
- same conv with 3-4 turns - 60% -> 85% cache ratio || 0.31 credits ->
0.13
# Introduction
Following connected account permissions refactor and encryption
Removing the old workspace schema twenty standard application
connectedAccount objects and related standard fields and index
- a lot of deadcode
- instance command backfill cleaning the connected account object from
workspaces
## Summary
Fixes the reply account resolution path so email replies use the
connected account associated with the thread's message channel instead
of blindly selecting the first connected account.
This targets the failure mode reported in #20658 where replying can
surface `SMTP is not configured for connected account` even though the
thread's actual IMAP/SMTP account has SMTP configured.
## Changes
- Query `myMessageChannels` alongside `myConnectedAccounts` in
`useEmailThread`
- Resolve the latest message's `messageChannelId` to its
`connectedAccountId`
- Return the matching connected account for reply context instead of
`myConnectedAccounts[0]`
- Add a regression test proving the hook chooses the thread channel's
account when the first account is different
## Validation
- `git diff --check`
I could not run the full frontend test locally in this workspace because
dependency installation failed with `ENOSPC: no space left on device`
while Yarn was cloning a dependency into the Windows temp/cache path.
The code change is intentionally narrow and covered by the added hook
regression test.
---------
Co-authored-by: neo773 <neo773@protonmail.com>
## Context
In the record-page edit mode, the "Add widget" section has three menu
items:
`Fields group`, `Field`, and `More widgets`. `Fields group` creates the
widget, focuses it, and opens its settings side panel. `Field` only
added the widget to the draft — no focus, no panel — which left the user
without visible confirmation or a way to immediately edit the new
widget.
## Change
Updated `useCreateRecordPageFieldWidget` to mirror
`useCreateRecordPageFieldsWidget`:
After appending the new widget to the draft, it now sets
`pageLayoutEditingWidgetIdComponentState` to the new widget's id and
navigates the side panel to `SidePanelPages.RecordPageFieldSettings`
(with `focusTitleInput: true`, `resetNavigationStack: true`).
This matches the behavior of clicking an existing field widget in
`useOpenWidgetSettingsInSidePanel` (`WidgetType.FIELD` branch).
# Introduction
This PR is a followup of https://github.com/twentyhq/twenty/pull/20673
It aims to unify the authentication/permissions layer with all the
connectedAccount interactions across the application
## Deprecate
- findAll
- findById
## Email sync
An user can only sync the message of his own connected account
## Workflow email
- Related https://github.com/twentyhq/private-issues/issues/478
- Only reauthorize owned account
## Summary
Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.
The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.
## What changes
**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.
**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.
**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.
## Why this over #20717#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.
Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.
## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
# Introduction
Prevent any cross user `connectedAccount` `connectionParamaters` leak
Also encrypt in db all `connectionParameters` password
Never return any password through `DTO` anymore
The settings now allow update mutation without providing the password in
edition mode
Verified all `connectionParameters.password` interaction
## Integration tests
- Added more coverage for both failing and successful paths
- Introduced a new env var that allow bypass the provider connection
test
## Legacy connected Account decryption support
Stop allowing non encrypted decryption on `accessToken` and
`refreshToken`, only allow legacy decryption on refactored
`connectionParameters`
## Upsert ownership
Completely got rid of the connected workspace schema context which is
legacy
Also now a user can only upsert a connected account for him only..
## New UI
<img width="1770" height="1852" alt="image"
src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b"
/>
If in edition the password is by default disabled
It needs to be selected as being edited to be enabled
## Next
- Refactor tool permissions flag not to include connected accounts
- Remove the legacy connected standard object
- Refactor and improve connected account resolver auth
## Summary
Two relation-traversal bugs surfaced post-merge of #20533, both rooted
in the same architectural smell: the GraphQL filter dispatcher took a
flat `fields: FieldShared[]` array and silently dropped any filter whose
`relationTargetFieldMetadataId` wasn't in that array. Callers had to
remember to pre-augment the list with relation targets — and 16+ call
sites did not all know this.
This PR fixes both bugs and removes the smell.
### Bug 1 — Save as new view loses the relation target
`useCreateViewFromCurrentView` built the create-filter input without
`relationTargetFieldMetadataId`. The saved view's filter persisted
without the traversal — on reload the chip showed "Company contains
'air'" instead of "Company → Name contains 'air'". Discarded at save
time, not at read time.
Fix: include `relationTargetFieldMetadataId` in the create input.
(Commit 1.)
### Bug 2 — Workflow Search Records drops one-hop traversals
`FindRecordsWorkflowAction` built its fields list from
`flatObjectMetadata.fieldIds` only (source object's fields). The shared
dispatcher then couldn't resolve the relation target field on the
related object and silently dropped the filter — a configured "People
where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`.
This was the same shape as bugs already fixed in 5 other call sites
(chart filters, view filters, record table, etc.). The pattern was:
caller forgets to augment fields → dispatcher silently drops the filter.
Fix (commit 2): change the dispatcher to take a
`findFieldMetadataItemById: (id) => FieldShared | undefined` resolver
callback. Both source-field and relation-target-field lookups go through
the same resolver, so callers no longer need to know about the
augmentation requirement. Frontend callers pass a workspace-wide
resolver built from `flattenedFieldMetadataItemsSelector`; server
callers wrap `findFlatEntityByIdInFlatEntityMaps` on
`flatFieldMetadataMaps`. In both cases relation-target lookups just
work, because the resolver can see fields on related objects.
## Why this matters
Before: "if you call the dispatcher, pre-augment your fields list with
relation targets, or filters get silently dropped." An invariant only
enforceable by code review, broken often enough to ship two user-visible
bugs in one week.
After: the dispatcher resolves field ids itself. There's no list to
forget to augment. The failure mode (filter silently dropped) becomes
structurally impossible at the dispatcher boundary.
Net diff: 240 insertions, 319 deletions. Removed
`augmentFieldsWithRelationTargets` (frontend) and the workflow
whack-a-mole code (server).
## Test plan
- [ ] Save view: create an advanced filter using a one-hop relation
traversal, click "Save as new view", reload, confirm the chip still
reads "Source → Target operator value"
- [ ] Workflow: configure a Search Records action with a
relation-traversal filter, run the workflow, confirm the filter is
actually applied
- [ ] Dashboard chart: configure a chart with a relation-traversal
filter, confirm the chart data respects it
- [ ] Record table, group-by, calendar, total count, footer aggregates:
all continue to work with both plain and relation-traversal filters
## Context
Reported in production on `engineering.twenty.com` — standalone page
layouts (e.g. "Release overview") crash with the React error boundary
fallback ("Sorry, something went wrong"). The console shows:
```
Error: useTargetRecord must be used within a record page context (targetRecordIdentifier is required)
```
The minified stack trace points at `SidePanelToggleButto…`, but that's
just the bundle chunk name — the actual call site is
`PageLayoutTabsRenderer`.
## Root cause
#19296 added an unconditional `useTargetRecord()` call inside
`PageLayoutTabsRenderer` so it could read the target object's metadata
and hide tabs whose widgets reference deactivated relations:
```ts
const targetRecord = useTargetRecord();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetRecord.targetObjectNameSingular,
});
```
But `PageLayoutTabsRenderer` runs on **both** record pages and
standalone pages. On standalone pages, `StandalonePageLayoutPage`
intentionally sets `targetRecordIdentifier: undefined` in
`LayoutRenderingProvider`, which makes `useTargetRecord()` throw — and
the follow-up `useObjectMetadataItem()` would also throw on miss.
Split of #20377.
## Summary
This PR separates available permission flags from per-role permission
flag grants.
Previously, `core.permissionFlag` stored the role assignment directly:
`roleId + flag`. This PR renames that legacy grant table to
`core.rolePermissionFlag`, then recreates `core.permissionFlag` as the
catalog of available permission flags.
## What changed
- Rename the existing `core.permissionFlag` grant table to
`core.rolePermissionFlag`.
- Add the new syncable `core.permissionFlag` catalog entity with key,
label, description, icon, permission type, relevance flags, and
custom/standard metadata.
- Add stable `SystemPermissionFlag` universal identifiers for the
built-in `PermissionFlagType` values.
- Seed the standard permission flags for every workspace under the
Twenty standard application.
- Backfill existing role grants:
- create missing catalog rows for existing grant keys,
- add `rolePermissionFlag.permissionFlagId`,
- migrate grants from the old string `flag` column to the new catalog
FK,
- replace the old `(flag, roleId)` uniqueness with `(permissionFlagId,
roleId)`.
- Rewire role permission flag caches, permission checks, role DTO
mapping, and `upsertPermissionFlags` to resolve through the catalog.
- Keep the existing public role permission API shape: product/app
surfaces still talk about `permissionFlags` and return `{ id, roleId,
flag }`.
- Update metadata flat-entity machinery, migration builders, validators,
action handlers, snapshots, generated schemas, docs, and app fixtures
for the new `permissionFlag` / `rolePermissionFlag` split.
## Behavior after this PR
- Existing permission flag grants keep working.
- Existing GraphQL role permission flows keep the same public naming.
- Standard permission flags are represented as catalog rows.
- Permission checks now compare grants through catalog universal
identifiers instead of the legacy `flag` column.
- Workspace deletion cleanup now verifies both `permissionFlag` and
`rolePermissionFlag`.
## What is not in this PR
- Public GraphQL CRUD for custom permission flags.
- App manifest support for declaring new custom permission flags.
- Frontend UI for creating or assigning custom permission flags beyond
the existing role permission flow.
---------
Co-authored-by: Weiko <corentin@twenty.com>
## Summary
- Replaced inline `<span>` wrapping the currency icon with a Linaria
styled component using `display: flex` and `align-items: center`
- The icon was misaligned with the amount text in table views and
settings because the inline span didn't vertically center the SVG icon
## Changes
-
`packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx`
Fixes#20640
## Summary
- Added `color: ${themeCssVariables.font.color.primary}` to
`StyledPageInfoTitleContainer` in `SidePanelPageInfoLayout.tsx`
- The "Update records" title had no explicit color, so it didn't adapt
to dark mode and was nearly invisible against the dark background
- Now correctly uses the theme-aware primary font color
## Changes
-
`packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfoLayout.tsx`
Fixes#20627
## Summary
- Cap password length at 50 characters in the shared regex used by
sign-up, password reset, and password change (both `twenty-front` and
`twenty-server`).
- Update the user-facing validation message on sign-up and password
reset to mention both the 8 min and 50 max bounds.
- Extend the `PASSWORD_REGEX` unit test to cover the new upper bound.
The cap also prevents unbounded inputs from reaching bcrypt, which
silently truncates passwords above 72 bytes and can mask user-visible
bugs.
## Test plan
- [x] `npx jest src/modules/auth/utils/__tests__/passwordRegex.test.ts`
passes (8-char min and 50-char max).
- [ ] Sign up with a 51-character password — form rejects with "Password
must be between 8 and 50 characters".
- [ ] Sign up with an 8–50 character password — succeeds.
- [ ] Password reset rejects a 51-character password with the same
message.
- [ ] Existing users with longer passwords (if any pre-exist) can still
sign in (the regex only gates write paths: sign-up, change, reset).
**Stacked on #20527**
https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd
## Summary
Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.
No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.
## What landed
| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |
`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.
## Test plan
- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)
## Out of scope
- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
- Adds a new admin-only **Security** tab to the Admin Panel (alongside
General/Apps/AI/Config/Health) containing a **Signing Keys** section.
The tab is intentionally introduced now so the upcoming **Encryption
rotation** work can land as a sibling section.
- Lists every JWT signing key with key id, `createdAt`, `revokedAt`,
current/active/revoked status, and a **7-day verification count** read
from Redis. A trailing row aggregates **legacy HS256** verifications so
it is clear when the deprecated path is still in use.
- Lets an admin **revoke** a public key. Revoking the current key drops
`isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and
clears the in-process cached current key; the existing lazy path in
`JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current
key on the next sign.
## Backend
- `SigningKeyVerifyCounterService` — bucketed Redis counter under the
existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL
refreshed on every increment, batched read via `mget`. Failures are
swallowed and logged at `warn` so a Redis hiccup cannot break auth.
- `JwtWrapperService.verifyJwtToken` records verifies **after success**
for both ES256 (`kid` as identifier) and HS256 (the literal `legacy`
identifier).
- `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`:
list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent,
validates the UUID, invalidates the public-key cache, and resets the
cached current-key promise.
- `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey`
(mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they
are admin-only, like the 35 existing admin-only methods on this
resolver. `privateKey` is never returned over GraphQL.
## Frontend
- New `SECURITY` tab id wired into `SettingsAdminContent` and
`SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`).
- `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly
reuse existing admin-panel components: `Section`, `H2Title`,
`Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`,
`Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the
queue retry/delete modals. Only one minimal styled helper for the
monospaced UUID rendering.
- `useRevokeSigningKey` uses `useApolloAdminClient`, refetches
`GetSigningKeys`, shows success/error snackbars (same pattern as
`useRetryJobs`/`useDeleteJobs`).
<img width="1293" height="881" alt="image"
src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c"
/>
## Summary
After a user completes a multi-workspace social-SSO sign-in,
[auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011)
issues a **workspace-agnostic** access + refresh token pair and lands
them on `app.twenty.com/welcome?tokenPair=…`.
[SignInUpGlobalScopeFormEffect.tsx](packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx)
reads the URL param, writes the cookie, pushes them to
`SignInUpStep.WorkspaceSelection`.
The problem: if the user revisits `app.twenty.com/welcome` later (e.g.
ChatGPT pings `/authorize` and the global page-change effect redirects
them to `/welcome` with `returnToPath=/authorize?…`), the existing
branch is a no-op — the URL param is gone. The user sees the regular
email/SSO form and has to re-authenticate, even though the
workspace-agnostic cookie is still valid.
This PR adds a second branch in the same `useEffect` that handles the
"valid cookie, no URL param" case:
```ts
if (signInUpStep !== SignInUpStep.Init) return;
if (!hasAccessTokenPair) return;
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
```
Single `useEffect`, no `useRef`, no async then/catch. The synchronous
`setSignInUpStep(WorkspaceSelection)` is the gate — once the step
transitions, subsequent effect runs early-return. Mirrors the existing
URL-param branch's pattern exactly.
If the cookie is stale, `loadCurrentUser` triggers Apollo's renewal
middleware. Renewal of a workspace-agnostic refresh token is supported
end-to-end (verified in audit, see below) — if it succeeds the user sees
their workspaces; if both tokens are expired, `onUnauthenticatedError`
clears the cookie and the next render lands them on the regular sign-in
form. Same fallback as if the cookie had never been there.
## Behavior matrix
| State on /welcome mount | Before | After |
|---|---|---|
| No tokenPair anywhere | Show sign-in form | Show sign-in form |
| tokenPair in URL (just bounced from SSO) | Set tokens →
WorkspaceSelection | (unchanged) Set tokens → WorkspaceSelection |
| tokenPair in cookie, access valid | Show sign-in form ❌ | **→
WorkspaceSelection ✓** |
| tokenPair in cookie, access expired, refresh valid | Show sign-in form
(Apollo eventually 401s on a query) | Renewal succeeds silently →
WorkspaceSelection ✓ |
| tokenPair in cookie, both expired | Show sign-in form |
`onUnauthenticatedError` clears cookie → fall back to sign-in form |
## Workspace-agnostic renewal: confirmed working end-to-end
Audit summary:
- **Refresh token carries the type**:
[refresh-token.service.ts:104](packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts)
preserves `targetedTokenType` in the JWT payload and returns it from
`verifyRefreshToken`.
- **Renewal branches on type**
([renew-token.service.ts:70-87](packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts)):
```ts
const accessToken =
isDefined(authProvider) &&
targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC &&
!isDefined(workspaceId)
? await
this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken({...})
: await this.accessTokenService.generateAccessToken({...});
```
Renewed refresh token preserves `targetedTokenType` (line 93).
- **Resolver is workspace-agnostic**: `@UseGuards(PublicEndpointGuard,
NoPermissionGuard)` on `renewToken`
([auth.resolver.ts:796-804](packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts))
— no `@AuthWorkspace()` requirement, callable from `app.twenty.com`.
- **Frontend middleware is type-agnostic**:
[apollo.factory.ts:180-209](packages/twenty-front/src/modules/apollo/services/apollo.factory.ts)
just passes the refresh token blob.
Net: no backend change needed. The full workspace-agnostic lifecycle
(issue → cookie → renew → re-issue) already works.
## Test plan
- [x] `npx oxlint` + `prettier --check` — clean.
- [x] `npx nx typecheck twenty-front` — clean.
- [ ] Manual: complete one full SSO flow ending on a workspace
subdomain. Visit `https://app.twenty.com/welcome` directly — expect the
workspace picker, not the sign-in form.
- [ ] Manual: same but with tokenPair cookie cleared — expect the
regular sign-in form (no regression).
- [ ] Manual: sign-out from a workspace, then visit
`app.twenty.com/welcome` — expect the regular form (sign-out clears the
cookie via full page reload).
- [ ] Manual: stale/expired tokenPair cookie — Apollo renewal kicks in
transparently; if renewal fails, regular form (no infinite loop, no
crash).
- [ ] Manual: pair with #20572 — visit `app.twenty.com/authorize?…` with
a stale workspace-agnostic cookie. Expected chain: `/authorize` renders
→ `PageChangeEffect` redirects to `/welcome?returnToPath=/authorize?…` →
this effect lands the user on WorkspaceSelection → picking a workspace
bounces to `<workspace>/authorize?…` where consent renders.
## Out of scope
- Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users
(separate cookie-scoping issue, tracked separately).
## Summary
Fixes the blank `/authorize` page reported when a user reopens the OAuth
consent screen on `app.twenty.com` after a prior multi-workspace SSO
sign-in.
### Reproduction
1. ChatGPT (or any MCP client) opens
`https://app.twenty.com/authorize?client_id=…` while signed out.
2. User picks "Continue with Google", lands in workspace selection,
picks a workspace, authorizes the app. Works.
3. Some time later, ChatGPT re-opens
`https://app.twenty.com/authorize?client_id=…`.
4. **Observed:** fully blank page, no console errors. Deleting the
`tokenPair` cookie unblocks it.
### Root cause
The multi-workspace social-SSO branch
([auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011))
lands the user on `app.twenty.com/welcome?tokenPair=…` with a
workspace-agnostic token. `SignInUpGlobalScopeFormEffect` writes that
into the host-scoped `tokenPair` cookie on `app.twenty.com`, and nothing
clears it after the user proceeds to a workspace subdomain. On the next
visit to `app.twenty.com/authorize?…`, `MinimalMetadataGater` sees
`hasAccessTokenPair === true` and renders `<UserOrMetadataLoader />`
instead of `<Authorize />`. The loader never goes away because:
- `IsMinimalMetadataReadyEffect` waits for `metadataStore.status ===
'up-to-date'`.
- `MinimalMetadataLoadEffect` only loads metadata when
`hasAccessTokenPair && isActiveWorkspace`, and the default domain has no
workspace context.
Skeleton loader stays forever → user perceives "blank page".
Some users get rescued by `WorkspaceProviderEffect` auto-redirecting
them to their last-authenticated workspace subdomain, but that cookie is
set with `domain: .twenty.com` and silently fails to persist for users
on custom domains — so the bug is most visible there.
### Fix
`/authorize` only issues `findApplicationRegistrationByClientId`, which
is a `PublicEndpointGuard` query. It doesn't need workspace metadata.
Add it to the gater's excluded-paths list alongside the existing
pre-auth pages (`SignInUp`, `Verify`, `Invite`, …) so the page renders
immediately regardless of token state.
This is a one-line, minimum-blast-radius fix. Two related cleanups are
separate concerns and not addressed here:
- Clearing the workspace-agnostic `tokenPair` cookie on `app.twenty.com`
after workspace selection.
- Fixing `lastAuthenticatedWorkspaceDomain` propagation for
custom-domain users.
## Test plan
- [x] `npx oxlint` + `prettier --check` on the touched file — clean.
- [x] `npx nx typecheck twenty-front` — clean.
- [ ] Manual: with a stale `tokenPair` cookie set on `app.twenty.com`,
open `https://app.twenty.com/authorize?client_id=<valid>&…` — consent
screen renders, no blank.
- [ ] Manual: signed-out → open the same URL — still redirects to
`/welcome` and back through the flow.
- [ ] Manual: signed-in on a workspace subdomain → open
`https://app.twenty.com/authorize?…` — `WorkspaceProviderEffect`
auto-redirect still kicks in (unchanged behavior).
## Summary
- Both \`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED\` and
\`IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED\` are force-enabled on
every existing workspace by the 1.23.0 upgrade command
\`BackfillRecordPageLayoutsCommand\` and seeded enabled for new
workspaces via \`DEFAULT_FEATURE_FLAGS\` +
\`seed-feature-flags.util.ts\`. They are no longer load-bearing.
- Unwrap all \`if (flag) { … }\` conditionals to their enabled branch on
both server and front.
- Delete legacy fallback files that only the disabled branch reached:
\`PageLayoutRelationWidgetsSyncEffect\`,
\`usePageLayoutWithRelationWidgets\`,
\`reInjectDynamicRelationWidgetsFromDraft\`,
\`injectRelationWidgetsIntoLayout\`, \`isDynamicRelationWidget\` (and
their tests).
- Strip the two \`enableFeatureFlags\` calls from the 1.23 upgrade
command — the page-layout backfill data logic itself is kept intact
since old workspaces upgrading from < 1.23 still need it.
- No DB cleanup migration: stale \`featureFlag\` rows are left in place,
matching the precedent set by #20531 and #20460.
Net diff: 37 files, +106 / -1727.
## Test plan
- [x] \`npx nx typecheck twenty-shared twenty-server twenty-front\` —
all pass
- [x] \`npx nx lint:diff-with-main twenty-server twenty-front\` — all
pass
- [x] \`cd packages/twenty-front && npx jest page-layout\` — 1240 tests,
all pass
- [x] \`cd packages/twenty-server && npx jest
workspace-entity-manager.spec\` — pass
- [ ] Manual smoke: open a record page, verify tabs render and \"Edit
Layout\" command-menu action is available
- [ ] Manual smoke: Settings → Data model → object → Layout tab is
visible (and hidden for remote / Dashboard objects)
- [ ] Manual smoke: edit a tab title, save, reload — confirm persistence
## Summary
Fixes the consent-modal-not-reopening half of
[#20535](https://github.com/twentyhq/twenty/issues/20535): when a
signed-out user opens an OAuth `/authorize?...` URL (e.g. ChatGPT
connecting to `api.twenty.com/mcp`) and signs in with **Google or
Microsoft**, the original `/authorize` request was lost and the consent
screen never reopened.
### Root cause
`PageChangeEffect` already saves the deep link as `returnToPath` (Jotai
atom) before navigating to `/welcome`. That atom is in-memory: it
survives SPA navigation, and the cross-subdomain workspace hop is
handled by `useBuildSearchParamsFromUrlSyncedStates` round-tripping the
value through the URL.
But the social-SSO path leaves `app.twenty.com` entirely —
`app.twenty.com/welcome` → `api.twenty.com/auth/google` → Google →
`api.twenty.com/auth/google/redirect` → frontend — so the atom is wiped.
None of the existing code paths plumbed `returnToPath` through that hop:
- `useAuth.buildRedirectUrl` packed `workspaceInviteHash`/`action`/etc.
but not `returnToPath`.
- `SocialSSOState` / the Google + Microsoft strategies didn't carry it
through the OAuth `state` blob.
- `signInUpWithSocialSSO` + `computeRedirectURI` didn't re-emit it on
the redirect back to the frontend.
The email path worked because all transitions stay on the default
frontend domain, so the atom survives until `SignInUpGlobalScopeForm`
bakes it into the workspace URL.
### What changed
Plumb `returnToPath` through the SSO state the same way
`workspaceInviteHash` and `action` already flow:
- **Frontend** (`useAuth.buildRedirectUrl`): read `returnToPath` from
the Jotai store and append it to `/auth/google` / `/auth/microsoft` when
set and structurally valid.
- **Server types** (`SocialSSOState`, `GoogleRequest['user']`,
`MicrosoftRequest['user']`): add optional `returnToPath`.
- **Strategies** (`google.auth.strategy.ts`,
`microsoft.auth.strategy.ts`): include `returnToPath:
req.query.returnToPath` in the JSON `state` and read it back in
`validate`.
- **auth.service.ts** (`signInUpWithSocialSSO`, `computeRedirectURI`):
forward `returnToPath` on both branches — the multi-workspace redirect
to `AppPath.SignInUp?tokenPair=...` and the single-workspace redirect to
`<workspace>/verify?loginToken=...`. Validated via a new
`isValidReturnToPath` helper so a tampered query value can't become an
open-redirect vector.
After the round-trip, `useInitializeQueryParamState` rehydrates the atom
from the URL and `usePageChangeEffectNavigateLocation` resolves it as
the post-auth destination — same mechanism the email path already relied
on.
Out of scope: the OAuth `resource` parameter handling tracked in
[#20296](https://github.com/twentyhq/twenty/issues/20296) is independent
and not addressed here.
## Test plan
- [x] `npx jest src/engine/core-modules/auth` (twenty-server) — 27
suites / 183 tests pass, including new
`is-valid-return-to-path.util.spec.ts`.
- [x] `npx jest src/modules/auth` (twenty-front) — 13 suites / 52 tests
pass, including two new cases in `useAuth.test.tsx` covering the happy
path and the protocol-relative open-redirect guard.
- [x] `npx nx typecheck twenty-server` / `twenty-front` — clean.
- [x] `npx oxlint` + `prettier --check` on touched files — clean.
- [ ] Manual: signed-out user opens
`https://app.twenty.com/authorize?client_id=...` → Continue with Google
→ completes Google → selects workspace → consent screen renders.
- [ ] Manual: same flow, single workspace — lands on consent screen
directly after Verify.
- [ ] Manual: email path still works (regression).
- [ ] Manual: tamper `returnToPath=//evil.com` on the `/auth/google` URL
→ server validation rejects, user lands at default home, not at
`evil.com`.
E2E note: existing `return-to-path.spec.ts` already covers deep links
with query params through the email path. A mock OAuth provider would be
needed to cover the SSO path end-to-end; unit coverage stands in for
now.
## Summary
Lets users choose which sub-field of a composite column to sort by —
directly from the sort chip — by clicking the sub-field label and
picking from a dropdown. Persists per view via a new nullable
\`subFieldName\` column on \`ViewSort\`.
Replaces #20438, which proposed a field-settings (admin) configuration
for the same problem. The chip-level approach is more discoverable (the
option lives where the user is looking) and per-view, so different views
on the same object can sort by different sub-fields.
### What changes for users
- **FullName columns**: previously sorted by \`firstName\` and
\`lastName\` together as a stable dual-key sort. Now the user can pick
which sub-field is primary (the other is the tie-breaker). Default
remains \`firstName\` primary, \`lastName\` tie-breaker.
- **Address columns**: previously not sortable at all (not in
\`SORTABLE_FIELD_METADATA_TYPES\`). Now sortable, with a chip dropdown
listing each enabled sub-field. Default is \`addressCity\` if enabled,
else the first enabled sub-field. Disabling a sub-field at the
field-metadata level (existing setting) removes it from the dropdown.
- **Other composite types** (Currency, Phones, Emails, Links, Actor) and
scalar fields keep their existing single-key sort behavior.
### UX
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ ↑ Name · Last name ✕ │ │ ↑ Address · City ✕ │
└────────┬────────────────┘ └────────┬────────────────┘
▼ (click sub-field) ▼
┌────────────┐ ┌────────────┐
│ First name │ │ Address 1 │
│ Last name ✓│ │ Address 2 │
└────────────┘ │ City ✓│
│ State │
│ Postcode │
│ Country │
└────────────┘
```
The chip body still toggles direction on click — the \`Dropdown\`'s
internal wrapper calls \`stopPropagation\` so the sub-field click
doesn't bubble to the chip's onClick.
## What changed
**Backend:**
- \`ViewSortEntity\` — new nullable \`subFieldName: varchar\` column
- \`ViewSortDTO\`, \`CreateViewSortInput\`,
\`UpdateViewSortInputUpdates\` — new \`@Field(() => String, { nullable:
true })\`
- \`FLAT_VIEW_SORT_EDITABLE_PROPERTIES\` — \`'subFieldName'\` added so
the property flows through the update merge path
- \`ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME.viewSort\` —
new \`subFieldName\` entry with \`toCompare: true\` so cache diffs
notice it
- \`fromCreateViewSortInputToFlatViewSortToCreate\` — threads
\`subFieldName\` through
- Instance command migration (\`add-sub-field-name-to-view-sort\`) —
single \`ALTER TABLE core.viewSort ADD subFieldName varchar\` / \`DROP\`
**Frontend:**
- \`RecordSort\` and \`ViewSort\` types — \`subFieldName?: string |
null\`
- \`VIEW_SORT_FRAGMENT\` — adds \`subFieldName\` so the field
round-trips
- \`mapRecordSortToViewSort\` + \`areViewSortsEqual\` — carry the new
field through, include it in the diff so the usual
\`useSaveRecordSortsToViewSorts\` create/update flow fires when it
changes
- \`useSaveRecordSortsToViewSorts\` — passes \`subFieldName\` in both
\`CreateViewSortInput\` and \`UpdateViewSortInputUpdates\`
- \`getOrderByForFieldMetadataType(field, direction, subFieldName?)\` —
new optional third arg. \`turnSortsIntoOrderBy\` threads
\`sort.subFieldName\` into it.
- \`Address\` added to \`SORTABLE_FIELD_METADATA_TYPES\`
- New helpers: \`getEnabledAddressSubFields\` (filters by the field's
\`subFields\` setting, falls back to the 6 default visible address
sub-fields), \`getDefaultSortSubFieldForAddress\`,
\`getDefaultSortSubFieldForFullName\`
- New shared types/constants: \`AllowedFullNameSubField\`,
\`ALLOWED_FULL_NAME_SUBFIELDS\`, \`DEFAULT_VISIBLE_ADDRESS_SUBFIELDS\`
- \`SortOrFilterChip\` — new \`labelSubField?: ReactNode\` slot; renders
as \` · {sub-field}\` with subdued weight after the main label
- \`EditableSortChip\` — builds options from field metadata
(\`ALLOWED_FULL_NAME_SUBFIELDS\` for FullName,
\`getEnabledAddressSubFields\` for Address), uses i18n-wrapped labels,
persists picks via \`upsertRecordSort\`
## Test plan
- [x] \`npx nx typecheck\` passes for twenty-shared, twenty-front,
twenty-server
- [x] \`oxlint --type-aware\` on all 19 frontend + 9 server changed
files: 0 errors
- [x] \`prettier --check\`: clean
- [x] 16 unit tests pass — \`getOrderByForFieldMetadataType\` covers the
new \`subFieldName\` override branch for FULL_NAME and ADDRESS;
\`getDefaultSortSubFieldForAddress\` covers the city/first-enabled
fallback path; \`getDefaultSortSubFieldForFullName\` exercises its
constant
- [ ] Manual: sort a People view by Full Name → click the chip's
sub-field label → switch between First name and Last name → reload page
→ choice is preserved
- [ ] Manual: sort a Company view by Address → confirm dropdown lists
only enabled sub-fields → disable Address \`addressCity\` in field
settings → confirm dropdown options update and runtime falls back to the
first enabled sub-field
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>