ea84aabe4c136587f2123fc67f84dc973d8da643
4856 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
431f6ae98f |
feat(settings): move settings chrome into a single rounded card (#21131)
## What Replaces `SubMenuTopBarContainer` with a settings-specific `SettingsPageLayout` that puts the whole page chrome — breadcrumb, centered title, actions, an optional secondary bar (tabs or wizard step), and the 760px body — inside **one rounded card**, with `SidePanelForDesktop` as a sibling. Title, tabs and body content share one centered vertical axis at every card width. Supersedes #21122. One PR, no feature flag. ## New components (`@/settings/components/layout/`) - **SettingsPageLayout** — owns the rounded card + side-panel sibling, `useCommandMenuHotKeys`, mobile command menu - **SettingsPageHeader** — breadcrumb · centered title · actions in a symmetric `1fr auto 1fr` grid (symmetric padding throughout) - **SettingsSecondaryBar** — the secondary row, bracketed by top + bottom borders - **SettingsTabBar** — centered tabs reusing `activeTabIdComponentState` + `TabListFromUrlOptionalEffect` for URL-hash sync (does not touch the shared `TabList`) - **SettingsWizardStepBar** — back arrow · "N. Label" · optional trailing slot ## Migrations - Bulk rename across ~80 call sites (`SubMenuTopBarContainer` → `SettingsPageLayout`); old component deleted. - 5 tab pages (AI, APIs & Webhooks, Applications, Members, Role) + the Data Model object-detail page render their tabs in `secondaryBar` (object-detail keeps "See records" / "New Field" in the header actions). - The 2 role object-level steps render the wizard step bar with working back navigation. - Accounts consolidated into **General / Emails / Calendars** tabs; standalone `SettingsAccountsEmails` / `SettingsAccountsCalendars` pages + routes + stories removed. `SettingsPath.AccountsEmails` / `AccountsCalendars` now resolve to `accounts#emails` / `accounts#calendars`, so existing `getSettingsPath()` links deep-link to the right tab via the existing hash sync — no call-site changes. ## Verification - `nx typecheck twenty-front` and `nx lint twenty-front` both clean. - Browser (logged-in workspace): title / tab / body / card centers align on a single axis at multiple widths — width-invariant, so alignment holds when the AI side panel (a sibling) shrinks the card. Rounded card with even gaps on all four sides; tab row bracketed by two 1px lines; no-tab pages render header → body with no lines; wizard back navigation works; `…/accounts#emails` opens the Emails tab. The shared `PageHeader` and `TabList` are untouched. The settings side panel itself isn't wired to open yet — that's a follow-up PR. |
||
|
|
2375c2f59c |
i18n - translations (#21141)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
58907b733c |
feat(logic-function): add LIVE / PREBUILT execution modes (#20873)
## Summary
### Why
1. Sending the code to the lambda (~1Mb usually) is heavy on network and
results to a constant traffic of ~30Mb/s on AWS which results into TB of
network data every month
2. eval(1MB of code) is not that fast, it's heavy on memory and CPU on
lambda side
### High level
Adds two execution modes for logic functions, gated behind the new
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` workspace feature flag (off
everywhere by default):
- **LIVE** (current behavior, preserved bit-for-bit): the compiled
bundle is read from object storage and shipped in every Lambda invoke
payload. Used for fast iteration in the workflow editor / Settings test
runs.
- **PREBUILT** (new): the bundle is installed onto the per-function
Lambda alongside the unified executor, and invocations carry only `{
params, env, handlerName }` — saving JSON payload egress and warm-start
`import()` cost on every call.
### Key design choices
- **Unified Lambda handler** (`constants/executor/index.mjs`) dispatches
at runtime: `event.code` present ? LIVE (write to `/tmp`, dynamic
import) : `import('./prebuilt-logic-function.mjs')`. Both code paths
always coexist on the deployment package, so the same Lambda can serve
either mode without redeploying.
- **Install runs inside the `validateBuildAndRun` migration pipeline**,
not at execute time. `Create/UpdateLogicFunctionActionHandlerService`
calls `driver.installPrebuiltBundle` when `executionMode` flips
LIVE?PREBUILT or `checksum` changes while PREBUILT, gated on
`isBuildUpToDate=true` and a fresh checksum.
- **Strict execute, no reconciliation**:
`LogicFunctionExecutorService.execute` resolves `effectiveExecutionMode`
(caller override > feature flag > entity column). For PREBUILT it asks
the driver `getInstalledBundleChecksum` (Lambda `twenty:bundle-checksum`
tag for AWS, sidecar file locally) and throws
`LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED` on mismatch.
- **Feature flag gates every side effect**: with the flag off the
executor forces LIVE, the action-handler install hooks bail before AWS,
and workflow activation does not flip the mode. Rollback is just turning
the flag off.
### Lifecycle
- New workflow CODE step ? `LIVE`, no install.
- Workflow activated ? build + activation flips `executionMode=PREBUILT`
? action-handler installs the bundle + sets the Lambda tag.
- Draft from active version ? duplicated logic function reset to `LIVE`.
- App install ? manifest converter sets `PREBUILT`, create-action
handler installs.
- Test runs (`executeOneFromSource`, workflow editor) pass
`executionMode=LIVE` explicitly.
### Observability
`[lambda-timing]` log lines now include `effectiveExecutionMode` and
`payloadBytes`; the action handler logs `install_duration_ms` for each
install.
## Test plan
- [x] `npx nx typecheck twenty-server` ? passes
- [x] `npx oxlint --type-aware` on all changed files ? 0 warnings, 0
errors
- [x] `npx nx test twenty-server` ? 588 suites / 5009 tests pass (no
regressions vs main)
- [x] New unit suite `flat-logic-function-validator.service.spec.ts` ?
9/9
- [x] Existing
`workflow-version-step-operations.workspace-service.spec.ts` ? 8/8
(verified the new token-based DI avoids a circular-import regression)
- [x] Snapshot for
`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` updated
to include `executionMode`
- [x] Integration suite `logic-function-execution.integration-spec.ts`
extended to assert `executionMode=LIVE` on newly-created functions and
continues to exercise the LIVE happy path
- [ ] Manual staging rollout: flip
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` per workspace, observe
`[lambda-timing]` `payloadBytes` drop + `install_duration_ms`, then ramp
in prod.
|
||
|
|
1ae00d6753 |
fix(ai): correct find-records tool description (top-level filter fields) (#21109)
## Problem
The AI `find_<object>` tool builds its input schema with
`generateFindToolInputSchema`, which **spreads field filters at the args
root** (alongside `limit`/`offset`/`orderBy`/`and`/`or`/`not`).
`tool-executor.service.ts` then maps the raw model args to a filter
with:
```ts
const { limit, offset, orderBy, ...filter } = args;
```
The zod schema is only used to generate the JSON schema *shown* to the
model (`z.toJSONSchema(...)`) — it is **never used to validate the args
coming back**. So when the model emits a bare operator where a field
name belongs, e.g. `{ ilike: "Foreman" }`, it passes straight to the
query runner, which throws and burns a retry mid-turn:
```
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field.
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "eq" field.
```
Two contributing faults:
1. **The tool description actively misleads the model** — it says ``use
filter: { id: { eq: "record-id" } }``, a `filter` wrapper the
root-spread schema doesn't have, inviting the malformed shape.
2. **No server-side validation** — invalid root keys reach the query
runner instead of being rejected against the advertised contract.
## Fix
1. **`FindRecordsService` prunes invalid filter keys before querying.**
Using the same filter shape the tool schema advertises
(`generateRecordFilterSchema(...).filterShape`), it drops any key that
is neither a real field nor a logical operator (`and`/`or`/`not`),
recursing through `and`/`or`/`not`. A model that sends `{ ilike:
"Foreman" }` now gets a valid (empty) filter rather than an exception.
Extracted as a pure, unit-tested util `pruneFilterToAllowedKeys`.
2. **Corrected the `find_<object>` tool description** to describe the
real top-level-field shape and explicitly warn against a `filter`
wrapper and bare root operators.
## Test
`__tests__/prune-filter-to-allowed-keys.util.spec.ts` covers: valid
filters untouched, bare root operators dropped, valid siblings
preserved, `and`/`or`/`not` recursion, and non-object input.
## Notes
- Defensive for all `FindRecordsService` callers; `find_one` (`{ id: {
eq } }`) and workflow find-records pass valid filters and are
unaffected.
- Companion to #21106 (RICH_TEXT composite filters). Both surfaced from
the same `"Tom Foreman's notes"` AI-chat repro; this PR addresses the
root-level-operator half.
---------
Co-authored-by: Rich Roberts <rich.roberts@talentpipe.ai>
|
||
|
|
6a908b7876 |
Front component s3 redirect (#21116)
# Introduction Unload the server of the file stream when possible Also fix inconsistent pipeline exception management Needs to highly be QA, not sure how the cors will behave here |
||
|
|
445c6fe9f6 |
feat: expose CURRENCY field settings (format/decimals) in shared types (#21090)
## What
Add a `CURRENCY` entry to `FieldMetadataSettingsMapping` (a
`FieldMetadataCurrencySettings` type of `{ format?: 'short' | 'full';
decimals?: number }`) so `FieldMetadataSettings<CURRENCY>` resolves to
the real settings shape instead of `null`.
## Why
The currency **format** (Short/Full) and **decimals** selectors already
ship in the field settings UI and persist through the generic `settings`
jsonb column — they render via
[`CurrencyDisplay.tsx`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx)
reading `settings.format` / `settings.decimals` (added in #12542 and
#16439).
But `twenty-shared` never got a `CURRENCY` entry in the settings
mapping, so `FieldMetadataSettings<CURRENCY>` is `null`. The SDK's
`defineField` derives its types from this mapping, so an app author
cannot set these from code — `universalSettings: { format: 'full',
decimals: 2 }` on a CURRENCY field is a type error, even though the
server stores and the frontend honours it. This aligns the type layer
with the already-shipped runtime behaviour.
## Changes
- `twenty-shared`: add `FieldMetadataCurrencySettings` +
`FieldCurrencyFormat`, wire the `CURRENCY` mapping entry, export
`FieldCurrencyFormat`.
- `twenty-server`: move `CurrencyFieldMetadata` from the
`NotDefinedSettings` assertions to a defined-settings assertion in the
field-metadata entity type test.
No runtime change — the server already accepts and stores these settings
via the generic jsonb column; this only makes them visible to the type
system and the SDK.
## Test plan
- [ ] `npx nx typecheck twenty-shared` / `twenty-server` pass
- [ ] In an app, `defineField({ type: FieldType.CURRENCY,
universalSettings: { format: 'full', decimals: 2 }, ... })` type-checks
and deploys
- [ ] Field renders with 2 decimals in full format, matching the
equivalent UI configuration
> Follow-up (not in this PR): the frontend keeps its own local
`fieldMetadataCurrencyFormat` / `FieldCurrencyFormat`; it could import
the shared `FieldCurrencyFormat` to de-duplicate.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1ba7a3fa54 |
fix: lowercase OAuth handle to prevent duplicate connected accounts (#21120)
Google and Microsoft connect flows stored the provider-returned email verbatim, so a re-auth with different casing (Sam@ vs sam@) missed the existing-account lookup and created a duplicate. Normalize the handle to lowercase at extraction, matching the SSO controller. Note: not doing backfill for now |
||
|
|
6ac797a69c |
fix: REST cursor encoding for nested order_by composite fields (#20974)
## Summary fix: REST cursor encoding for nested order_by composite fields Closes #20109 --- AI was used for assistance. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
75df1f3997 |
chore(settings): address review comments from PR 21072 (#21121)
## Summary Round through bosiraphael's 31 review threads on the merged PR #21072 (discovery hero + ephemeral playground token). The user asked to apply each suggestion only where it adds value, so this PR is split into three buckets. ### Comments (~17 threads) - Tightened security-rationale / CSS-gotcha / API-doc comments to one or two factual lines - Kept (shortened) the comments above `RequireAccessTokenGuard` call sites — without them a future reader could remove the guard and silently reopen the escalation hole - Kept (shortened) the in-memory-only rationale on `playgroundApiKeyState` for the same reason - Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer` — non-obvious and easy to break ### Structure / extraction - Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants file (one-export-per-file) - Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across queries/, hooks/, types/, utils/: - `graphql/queries/findManyApplicationsForToolTable.ts` - `graphql/queries/findManyMarketplaceAppsForToolTable.ts` - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging) - `types/SettingsAgentToolItem|Application|MarketplaceApp` - `utils/getToolApplicationId|getToolLink` - Extract `SettingsAiModelsTab` optimistic mutations into `hooks/useSettingsAiModelsActions` (handleModelFieldChange, handleUseRecommendedToggle, handleModelToggle, handleToggleAllVisibleModels) - Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool` - Drop unnecessary `useMemo` wrappers on `heroTabs` arrays (SettingsObjects, SettingsLayout) - Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab: `onToggleChange={setShowDeactivated}` (no longer wrapping with arrow + read of stale `!showDeactivated`) ### Hero assets - Replace placeholder `customize-illustration` with per-page exports - Rename `layout/customize-illustration-{light,dark}.png` → `layout/cover-{light,dark}.png` - Add `cover-{light,dark}.png` for **applications** and **members** (they were both pointing at the layout placeholder as a TODO) - Overwrite `data-model/cover-*.png`, `playground/cover-*.png`, `ai/ai-tools-cover-*.png` with the new exports ## Test plan - [ ] `npx nx typecheck twenty-front` ✅ - [ ] `npx nx typecheck twenty-server` ✅ - [ ] `npx nx lint twenty-front` ✅ (oxlint + oxfmt, 0 warnings/errors) - [ ] `/settings/layout`, `/settings/data-model`, `/settings/applications`, `/settings/ai`, `/settings/api-webhooks`, `/settings/members` each render the new hero illustration (light + dark) - [ ] AI tab: tool list still loads, search + Custom/Managed/Standard filters still work, "New Tool" still navigates to detail - [ ] AI tab: Models tab — smart/fast model select, "Use best models only" toggle, per-model checkboxes, toggle-all all still optimistic+revert on error - [ ] Skills tab: "Deactivated" toggle still flips show/hide - [ ] Webhooks table still uses the 1fr 28px grid |
||
|
|
d6b3527552 |
Public assets server s3 redirection (#21108)
# Introduction Avoid overloading the server on file streaming Take profit of the different origin implied by the redirection to the s3 Only concern being the expiration date on a public file which is acceptable closes https://github.com/twentyhq/private-issues/issues/483 related https://github.com/twentyhq/private-issues/issues/491 |
||
|
|
989b45db15 |
Strictly type encryption rotation key site maps constants through entity type derivation (#21085)
# Introduction Followup https://github.com/twentyhq/twenty/pull/21001 Now that the typeorm entities provide grains over their `encryptedString` value, we can strictly type the sitemaps of the encrypted string to rotate in case of encryption key rotation and also the integration tests tests cases |
||
|
|
d86e827563 |
fix: return proper FORBIDDEN GraphQL errors from ApiKeyResolver (#21107)
## Context CI is broken on main, regression introduced in https://github.com/twentyhq/twenty/pull/21072 Guard-rejected ApiKey mutations returned malformed GraphQL responses. RequireAccessTokenGuard (and SettingsPermissionGuard) throw plain AuthException/PermissionException classes, which are not GraphQLErrors. ApiKeyResolver had no @UseFilters, so these exceptions were never translated, they surfaced as request-level errors with no data key (data: undefined) and a non-FORBIDDEN code, instead of data: null + FORBIDDEN. This broke the `createApiKey › should reject a non-ACCESS token even with API key permission` integration test (expect(res.body.data).toBeNull() received undefined). The sibling generateApiKeyToken test passed only because it lives on AuthResolver, which already declares these filters. ## Fix Add the standard exception filters to ApiKeyResolver, matching the idiom used by other guard-protected resolvers ```ts @UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) ``` |
||
|
|
ba493e5a23 |
fix: show relation field changes in the timeline (#21052)
## Summary - Fixes #20970 - When we changed only a relation field (e.g. company on a person): - The diff builder skipped all RELATION fields -> empty diff -> no timeline row. - If we change company and something else, only the scalar field appeared; the company change was missing. - Even with a diff, the UI validated keys by field name (`company`) while join columns (`companyId`) could be filtered out. ## Solution - For `MANY_TO_ONE`, compare join column values (`companyId`) and store the diff under the relation field name (`company`): ``` "company": { "before": { "id": "<old-id>" }, "after": { "id": "<new-id>" } } ``` - Frontend - `filterOutInvalidTimelineActivities`: resolve diff keys by field name or join column via `findFieldMetadataItemByDiffKey`, so relation diffs are not stripped. - `EventRelationFieldDiffValues`: resolve related record labels by id; show only the new value in the row (same pattern as other fields: Company -> airSlate). - Tooltip (relations only): on hover, show before -> after with readable names (e.g. Microsoft -> Apple). Scalar and composite fields (e.g. Updated by) are unchanged and do not get this tooltip. - `EventFieldDiff`: route RELATION diffs to the relation renderer; all other field types keep the existing FieldDisplay behavior. ### What you’ll see On a person (or opportunity) timeline after changing company: ``` You updated Company → airSlate (tooltip: Microsoft → Apple) ``` ## Test plan - Change only company on a person -> timeline shows a company update with names. - Change company and name in one save -> both appear in the diff. - Clear company -> row shows Empty; tooltip reflects previous -> empty if applicable. - Same we can do for the Opportunities also - Scalar / ACTOR fields (e.g. Updated by) - no new tooltip; display unchanged. ## Screenshots ### Before <img width="527" height="124" alt="Screenshot 2026-05-29 155123" src="https://github.com/user-attachments/assets/e067f19a-8184-4e50-9cd0-9135e06188b8" /> <br><br> <img width="535" height="181" alt="Screenshot 2026-05-29 155149" src="https://github.com/user-attachments/assets/3c513a1e-c5c5-4cff-ae1c-5fed62837798" /> <br><br> ### After <img width="564" height="200" alt="Screenshot 2026-06-01 154539" src="https://github.com/user-attachments/assets/459ad6b4-af4c-4f7a-b749-30762c979627" /> <img width="567" height="187" alt="Screenshot 2026-06-01 154555" src="https://github.com/user-attachments/assets/6bc4711c-afca-43d8-b874-f51fc0f374df" /> <img width="563" height="188" alt="Screenshot 2026-06-01 154639" src="https://github.com/user-attachments/assets/f275b9fd-a1c3-4c4b-9538-8042657eb593" /> <img width="556" height="180" alt="Screenshot 2026-06-01 154654" src="https://github.com/user-attachments/assets/2e614368-8fd7-4c73-8876-2223f2f98e67" /> <img width="560" height="237" alt="Screenshot 2026-06-01 154802" src="https://github.com/user-attachments/assets/0437aef4-7b2c-4d35-a2e9-f617b90a1beb" /> --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: martmull <martmull@hotmail.fr> |
||
|
|
627b488556 |
Fix else branches not properly skipped in nested if/else workflows (#20938)
## Summary - Extract `findParentSteps` utility that recognizes IF-ELSE steps as parents of their branch children (via `settings.input.branches[].nextStepIds`), used in all parent detection sites (`shouldSkipStepExecution`, `shouldExecuteStep`, `shouldFailSafely`, and their iterator variants) - Centralize next-step resolution in `getNextStepIdsToExecute` via extracted `getNextStepIdsForIterator` and `getNextStepIdsForIfElse` utils — Iterator now properly returns loop children as `nextStepIdsToSkip`/`nextStepIdsToFailSafely` when skipped - Refactor `skipAndFailSafelyStepsThenContinue` to delegate to `getNextStepIdsToExecute` instead of duplicating type-specific propagation logic Fixes #20934 ## Test plan - [x] New unit tests for `findParentSteps` (7 tests covering IF-ELSE branch parent detection) - [x] New IF-ELSE-specific tests added to `shouldSkipStepExecution`, `shouldExecuteStep`, `shouldFailSafely` test suites - [x] Updated Iterator skip/fail-safely tests in `workflow-executor.workspace-service.spec.ts` - [x] All 300 workflow executor tests pass - [x] `lint:ci` passes |
||
|
|
b9e5ff2065 |
fix: broadcast timeline activities to live SSE subscriptions (#21104)
## Context Timeline activities never updated in real time. They were explicitly excluded from the database-event pipeline (formatTwentyOrmEventToDatabaseBatchEvent early-returned for the timelineActivity object), so no SSE event was ever broadcast, and the frontend timeline only refreshed on mount/manual refetch. ## Implementation Backend - Feat: Stop dropping timeline-activity events in formatTwentyOrmEventToDatabaseBatchEvent. Instead route them through EntityEventsToDbListener, which publishes them directly to live subscriptions (but still skipping webhook/audit handling). - Fix: Harden ObjectRecordEventPublisher: wrap nested-relation enrichment in try/catch so a failure broadcasts the event without relations instead of dropping it (logs a warning). - Fix: Skip unreadable relation targets in CommonSelectFieldsHelper when the role lacks canReadObjectRecords, preventing errors while computing selected fields. - Fix: Support MORPH_RELATION alongside RELATION in RLS row-level permission predicate matching (timeline activities use morph targets). Frontend - Feat: useTimelineActivities now registers the timeline query with the SSE system via useListenToEventsForQuery and refetches on incoming timeline-activity record operations. - Feat: Add a skip option to useListenToEventsForQuery so the listener isn't registered when the object has no timeline field. ## Test https://github.com/user-attachments/assets/ed1d1c66-d6ea-434d-ac9c-9b83d2b78338 Note: "UpdatedBy" seems to be listen to and visible in the timeline activity summary, this is probably a bug that we want to fix |
||
|
|
381ca32055 |
Billing - Fix credit upgrade invoice error (#21097)
In createImmediateUpgradeInvoice, the invoice is finalized with auto_advance: true, which causes Stripe to automatically attempt payment asynchronously. Then the explicit stripe.invoices.pay(invoice.id) call races against that auto-payment — if Stripe already paid it, this throws "Invoice is already paid". The fix is to finalize with auto_advance: false and keep the explicit pay call |
||
|
|
b338a7a1d2 |
feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
|
||
|
|
4dff30f676 |
Twenty server:Fix REST pagination issues (#20980)
Fixes #20109 The entry was repeating because in the database we store DateTime fields with microsecond precision (timestamptz), but when JS parses timestamptz into a Date object it only keeps millisecond precision. ### Example If previous cursor was: ``` { name: "Quick Lead", createdAt: "2026-05-21T15:33:00.708Z", } ``` The resulting query look something like: ``` ... WHERE ( "workflow"."name" > "Quick Lead" OR ( "workflow"."name" = "Quick Lead" AND "workflow"."createdAt" > "2026-05-21T15:33:00.708Z" ) OR ( "workflow"."name" = "Quick Lead" AND "workflow"."createdAt" = "2026-05-21T15:33:00.708Z" AND "workflow"."id" > "8b213cac-a68b-4ffe-817a-3ec994e9932d" ) ) ``` So, when comparing the 2nd condition the `"workflow"."createdAt" > "2026-05-21T15:33:00.708Z"` would always result to true because in db the data for createdAt is `2026-05-21 21:03:00.708 +0530` which will always be greater than `2026-05-21T15:33:00.708Z` The second condition `"workflow"."createdAt" > "2026-05-21T15:33:00.708Z"` always evaluates to true, because the value actually stored in the DB for createdAt is something like `2026-05-21 21:03:00.708264 +0530`, which is always greater than `2026-05-21T15:33:00.708Z` in the cursor. The row used to generate the cursor therefore reappears on the next page. ### My solution Truncate the column to milliseconds in the comparison so both sides have the same precision: `date_trunc('milliseconds', ${fieldReference})`. For the issue of nested sorting filters, when ordering by a composite field (e.g. `createdBy.name`), `encodeCursor` stored the entire composite object (`source`, `workspaceMemberId`, `name`, `context`). The where-condition builder later iterated those sub-keys and threw "Invalid cursor" because only name had an orderBy direction. P.S: Duplicate of #20867 because last fork got polluted. --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
f4380f89a8 |
fix: SSE event stream reconnection after idle connection death (#21061)
The SSE event stream could silently die from network partitions, NAT
table flushes, browser tab throttling, or server restarts. When this
happened:
1. The `error` callback only called `captureException` — no reconnection
was triggered
2. The `complete` callback was `() => {}` — a cleanly terminated stream
left the client permanently broken
3. No mechanism existed to detect a silently dead connection where no
FIN/RST was received
## Summary
- **Fix `error`/`complete` callbacks**: The `graphql-sse` subscription's
`error` callback only reported to Sentry, and `complete` was a no-op.
Both now set `shouldDestroyEventStreamState = true` to trigger the
destroy-recreate lifecycle, ensuring detected transport failures and
clean stream terminations lead to automatic reconnection.
- **Add server-side keepalive**: The existing heartbeat timer now runs
every 30s (instead of 6min) and publishes empty events through the Redis
pub/sub channel in addition to refreshing the Redis TTL (throttled to
~6min). Unlike GraphQL Yoga's opaque SSE comment pings, these are real
subscription events that flow through the client's `next`/`message`
handlers.
- **Add client-side keepalive monitor (`SSEKeepAliveEffect`)**: Tracks
the timestamp of the last received event. If no event arrives within 90
seconds (3x the keepalive interval), it clears query listeners and
triggers a stream destroy-recreate cycle.
## Test plan
- [x] Start the app, verify SSE events flow normally (workflow runs
update in real-time)
- [x] Leave the app idle for >90 seconds, then trigger a workflow run —
verify the stream auto-reconnects and events are delivered
- [x] Kill the server, restart it, verify the frontend recovers its
event stream
- [x] Verify keepalive events (empty
`objectRecordEventsWithQueryIds`/`metadataEvents`) appear in browser
network tab every ~30s
- [x] Verify no regressions in SSE-dependent features (record updates,
metadata changes, workflow run visualization)
|
||
|
|
e430e4ea0a |
fix(ai): route xAI search through Responses API as native tools (#21037)
xAI deprecated Live Search, so the `searchParameters` provider option now returns 410. This routes all xAI models through the Responses API and binds web/X search as native agent tools, matching how Anthropic/OpenAI expose search. - xAI provider now uses `provider.responses()` — its `webSearch()`/`xSearch()` tools only run against the Responses endpoint, not chat completions - web/X search migrated from the `provider-option` variant to `sdk-tool` (`web_search`/`x_search`); deleted the dead `searchParameters` path, the `provider-option` variant, and `providerOptions` on `NativeModelBinding` - dropped a dead `rolePermissionConfig` param on `getAgentRoleId`, left over from #20331 --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com> |
||
|
|
88b77cb699 |
feat(server): opt-in FRONT_AUTO_BASE_URL for hostname-relative API URL (#20504)
## Problem
`generateFrontConfig()` writes `window._env_.REACT_APP_SERVER_BASE_URL =
process.env.SERVER_URL` unconditionally. The frontend then pins to that
absolute URL. For self-hosted deployments reachable from multiple
hostnames (Tailscale IP, LAN IP, internal DNS, SSH tunnel to localhost,
public DNS), only the one matching `SERVER_URL` works — others hit CORS
errors or unreachable hosts because the frontend tries to call the API
at the configured URL, not the one the user came in via.
The frontend already supports the right fallback:
`packages/twenty-front/src/config/index.ts:20-21` reads
`window._env_?.REACT_APP_SERVER_BASE_URL` and falls back to
`getDefaultUrl()` (which uses `window.location`) when the env var is
absent. But the server-side `generateFrontConfig` always populates
`_env_`, so the fallback never runs.
## Fix
One file: `packages/twenty-server/src/utils/generate-front-config.ts`.
Add a `FRONT_AUTO_BASE_URL=true` opt-in (also triggered when
`SERVER_URL` is unset entirely). When the toggle is on, inject
`window._env_ = {}` so the frontend's existing `getDefaultUrl()`
fallback resolves the origin from `window.location` at runtime.
## Backwards compatibility
When `SERVER_URL` is set AND `FRONT_AUTO_BASE_URL` is unset (or anything
other than `'true'`): unchanged — `REACT_APP_SERVER_BASE_URL:
process.env.SERVER_URL` is injected exactly as before.
The toggle is strictly additive. Existing single-hostname deployments
are not affected.
## Use case
Self-hosted Twenty reachable via:
- `http://100.115.12.29` over Tailscale
- `http://localhost:4440` over SSH tunnel
- `http://twenty.internal` over LAN DNS
- `http://crm.example.com` public
With `FRONT_AUTO_BASE_URL=true`, all four paths work without rebuilds or
per-hostname server processes.
## Test plan
- [ ] `SERVER_URL=http://x.com` (toggle unset) → `<script>window._env_ =
{"REACT_APP_SERVER_BASE_URL":"http://x.com"};</script>` (unchanged from
main)
- [ ] `SERVER_URL` unset → `<script>window._env_ = {};</script>` (new
fallback path)
- [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` →
`<script>window._env_ = {};</script>` (toggle wins)
- [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged
(only `'true'` triggers the toggle)
---------
Co-authored-by: martmull <martmull@hotmail.fr>
|
||
|
|
10c0bed462 |
fix: harden email-group SES provisioning, cleanup, and inbound replay (#21046)
## Changes **Provisioning idempotent** (`aws-ses-register-domain.service.ts`) - Each SES create call (`CreateConfigurationSet`, event destination, contact list, tenant association) now swallow `AlreadyExistsException` via `.send().catch()`. - Retry after partial failure re-run every step, no blow up on "already exists". Before: one existing resource kill whole provision. **Workspace delete clean up cloud** (`workspace.service.ts`, `emailing-domain-workspace-cleanup.job.ts`, `emailing-domain.service.ts`) - On workspace delete, fetch domain list first, pass domains to cleanup job. - Cleanup now loop `driver.cleanupDomain(domain)` per domain + `deprovisionWorkspace`. Tear down SES identity/tenant/config-set, not just delete DB rows. - Before: DB rows gone, SES resources orphaned forever. Now: cloud match DB. **Inbound replay dedupe** (`ses-inbound-mail-handler.service.ts`) - Use `snsMessageId` as job id. SNS deliver same message twice → second is no-op. No duplicate inbound email import. |
||
|
|
c2df39405c |
Fix admin pannel server variable config tab (#21017)
## Before <img width="1046" height="490" alt="image" src="https://github.com/user-attachments/assets/450557de-fcf5-4b51-afdb-36c0c36e43d8" /> ## After <img width="1040" height="414" alt="image" src="https://github.com/user-attachments/assets/4a5fe2ab-85d6-4431-9397-6f81ae24055d" /> |
||
|
|
13f09d8946 |
[Dashboards] Remove gauge chart types and code (#20410)
Follow-up cleanup to #20172. |
||
|
|
961745e9ba |
Fix latency spike on application lookup (#21042)
Fixes https://discord.com/channels/1130383047699738754/1509645089062781058 Caching application entities to improve authentication latency |
||
|
|
41832c8d82 |
Fix workflow creation on view filtered by status (#21027)
Creating a workflow on a table with with a filter on status (eg: status is "active") failed because it added the status to createOneWorkflow (in order to have the record belonging to the view) - while createOneWorkflow throwed a 400 exception when attempting to create a workflow with a status (does not correpsond to a valid behaviour). Silently stripping status rom create workflow endpoints. |
||
|
|
3041ed3b6e |
chore: sync AI model catalog from models.dev (#21041)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
25b0e0d091 |
fix: correct typo occurence -> occurrence in metadata-event-emitter.ts (#21036)
## Summary Fixes a spelling typo in `packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-emitter.ts`: - Variable name `occurence` → `occurrence` (4 references on lines 101, 103, 114, 115) ## Changes - `packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-emitter.ts` — rename misspelled variable Co-authored-by: james <li@jamesdeMacBook-Pro.local> |
||
|
|
996cdaf3ff |
refactor(agents): split tool resolution into native and action rails (#20331)
## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
8a74ea8829 |
fix(contact-creation): enrich missing names on auto-created contacts (#21018)
## Summary
Three related fixes to the auto-creation of People records from calendar
events and email messages, all centred on the data-quality problem of
contacts being created with missing or malformed names.
### 1. Enrich names on existing contacts (commit 1)
Previously: when an email or calendar import matched an existing Person
by email, the existing record was left untouched — even if the new
source carried a better name.
This is the root cause of contacts like `"Félix"` (no last name)
sticking around forever: the `To:`/`Cc:` headers of outbound emails
rarely include a display name, and Google Calendar only returns
`displayName` for attendees already in the organizer's address book. So
the first sighting often creates a Person as `{firstName: "felix",
lastName: ""}`, and a later inbound `From: "Félix Malfait"
<felix@twenty.com>` — which would have produced the right name — gets
silently dropped because the Person already exists.
The new `computePeopleToEnrichNames` bucket and
`CreatePersonService.enrichPeopleNames` method fill in missing
`firstName`/`lastName` fields from the new parsed name, with
conservative rules:
- Only enrich when the existing Person's `createdBy.source` is
`CALENDAR` or `EMAIL` — `MANUAL`, `IMPORT`, `API`, `WORKFLOW`, etc. are
never touched.
- Only fill empty fields. Non-empty `firstName`/`lastName` are never
overwritten.
- Soft-deleted contacts continue to be handled by the existing restore
path.
### 2. Handle multi-comma "Last, First, Suffix" display names (commit 2)
The comma-inverted swap in the parser previously required *exactly* one
comma. Names like `"Smith, Jane, Jr."`, `"O'Brien, Mary, MD"` or `"Doe,
John, Patrick"` fell through to the space-split fallback, which stored
the comma in `firstName` (e.g. `"Smith,"`) and produced garbled records
(the avatar shows a single "B" and the name reads `"Barbey, Julien"`
because the entire string lives in `firstName`).
The regex now splits on the first comma and treats the remainder as the
first name, collapsing any further commas to spaces. Single-comma
behaviour is unchanged.
### 3. Perf: skip the parser when an existing record is already
populated (commit 3)
`computePeopleToEnrichNames` runs on every cron-driven email/calendar
import batch. The first version called the display-name parser for every
matched existing person, even when both `firstName` and `lastName` were
already set — i.e. the steady-state case after the initial enrichment
pass.
Reordered so the cheap "both fields populated" check short-circuits
before any parsing happens. Same behaviour, fewer parser calls on the
hot path.
## Test plan
- [x] 8 new unit tests for the enrichment bucket: empty `lastName`
enrichment, both `EMAIL` and `CALENDAR` sources, non-overwrite of
non-empty fields, skip on `MANUAL`/`IMPORT`, skip when the new source
also has no last name, skip for soft-deleted, fill `firstName` while
preserving `lastName`, handle null `name` field
- [x] 5 new parser tests for multi-comma forms: `"Last, First, Suffix"`,
credential suffixes (`MD`), three-token forms, whitespace around inner
commas, `:GROUP` tag interaction
- [x] 1 new parser test on the single-comma path covering multi-word
first names (`"Smith, Mary Jane"`)
- [x] All 15 existing parser tests still pass
- [x] All 116 tests in `contact-creation-manager` pass
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` + `npx oxfmt --check` on changed files
- [ ] Manual: trigger a fresh contact creation from an outbound email
with no display name, then a subsequent inbound email from the same
address with a full display name, and confirm the Person's last name
gets populated
|
||
|
|
f4ead89956 |
refactor(twenty-orm): migrate 23 grandfathered entities to WorkspaceScopedRepository (#20987)
## Summary Follow-up to #20953. Migrates 23 of the 30 entities that were left in `WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's workspaceId-enforcement default now covers most of the core/metadata schema. ### Migrated (23 entities, 88 files, 22 commits) | Family | Entities | |---|---| | Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`, `Webhook`, `CommandMenuItem`, `IndexMetadata` | | Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`, `ViewFilterGroup`, `ViewGroup`, `ViewSort` | | Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` | | Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`, `ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`, `RowLevelPermissionPredicateGroup` | For each entity: swap `@InjectRepository(X)` → `@InjectWorkspaceScopedRepository(X)` (and the field type → `WorkspaceScopedRepository<X>`); rewrite every call site to pass `workspaceId` as the first arg (stripped from `where`/criteria — the wrapper throws if you include it now); register `provideWorkspaceScopedRepository(X)` in every owning NestJS module; update affected spec providers to `getWorkspaceScopedRepositoryToken(X)`. ### Rule update - `ApplicationRegistrationVariableEntity` was misclassified — moved to `STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on `applicationRegistrationId` at the instance level). - 22 of the 23 migrated entities removed from `WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw `@InjectRepository` sites). - `RoleTargetEntity` also removed; one call site in `user-workspace.service.ts` keeps a raw injection with an `eslint-disable` + reason because `softRemove(...)` is not on the wrapper API yet (the migration would require threading `workspaceId` through `deleteUserWorkspace`'s three callers). ### Still exempted (7 entities, follow-up PRs) | Entity | Why deferred | |---|---| | `ApplicationEntity` | ~50 sites with several cross-workspace lookups by id (auth, OAuth, file-storage, cleanup) | | `CalendarChannelEntity` / `MessageChannelEntity` | Use `.increment(...)` (not on wrapper) and `repository.manager.transaction(...)` — wrapper needs to grow `.increment` + the transaction sites need `withManager` or dual-inject | | `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services `extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires dual-inject or reworking the inheritance | | `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for instance-level config; wrapper rejects null | | `UpgradeMigrationEntity` | Same — instance-level + cross-workspace ledger | ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — clean (0/0) - [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role, permissions, workspace-roles-permissions-cache, view-filter-group, workflow-version-step-operations, two-factor-authentication (service + resolver), user-workspace, file - [ ] Server integration tests in CI |
||
|
|
865ca697ca |
Fix AI permission gating: use Ask AI for chat UI, AI Settings for admin endpoints (#21030)
## Summary Closes #20662. Two AI permission flags exist: - **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI features - **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents After auditing every use of these flags I found: ### Frontend — chat UI gated by the admin permission (user-facing bug from the issue) A user granted only `Ask AI` could not see chat tabs, the "new chat" button (desktop & mobile), or the chat content pane; thread initialization was also skipped, leaving the chat in a half-initialized state and producing intermittent `THREAD_NOT_FOUND` errors. Switched these to `AI`: - `MainNavigationDrawerTabsRow.tsx` - `MainNavigationDrawer.tsx` - `MobileNavigationBar.tsx` - `AgentChatThreadInitializationEffect.tsx` ### Backend — admin-only resolvers gated by the user permission (privilege escalation) Two resolvers had a class-level guard of `AI`, letting any user with the user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched the class-level guards to `AI_SETTINGS`: - `SkillResolver` — create/update/delete/activate/deactivate skills - `AgentTurnResolver` — read turns, run/grade evaluations ### Left as-is (already correct) - `AgentResolver` — class-level `AI` for reads (workflow editors and admin pages both need them), mutation-level `AI_SETTINGS` overrides for writes - `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI` - `AiGenerateTextController` — already `AI` - Workspace AI config fields in `workspace.service.ts` — already `AI_SETTINGS` ## Test plan - [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new chat" button, and chat history pane are visible on desktop + mobile; sending a message works; no `THREAD_NOT_FOUND` errors - [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden - [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` / `agentTurns` / `runEvaluationInput` via GraphQL returns permission denied - [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages still work |
||
|
|
ebfaca5b3d |
EncryptedString PlaintextString branded string types (#21001)
## Summary closes https://github.com/twentyhq/core-team-issues/issues/2464 Introduces compile-time branded types to distinguish encrypted ciphertext from plaintext strings, preventing mix-ups like the one fixed in #20819 — but at the type level rather in addition to the one existing at runtime. ### Branded string primitives - Created `EncryptedString` and `PlaintextString` as hard nominal brands using `z.string().brand(...)`, making them non-assignable to each other or to raw `string` - Created `isEncryptedString` type predicate to narrow `string` to `EncryptedString` based on the `enc:v2:` envelope prefix - Retyped `SecretEncryptionService`: `encryptVersioned` accepts `PlaintextString`, `decryptVersioned` returns `PlaintextString` ### Entity typing - Typed encrypted columns across entities: `SigningKeyEntity.privateKey`, `TwoFactorAuthenticationMethodEntity.secret`, `ApplicationRegistrationVariableEntity.encryptedValue`, `ApplicationVariableEntity.value` - Parameterized JSONB types for connected account connection parameters (`ImapSmtpCaldavParams<Pwd>`) with reusable aliases `EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams` - Typed DTOs (`CreateApplicationRegistrationVariableInput`, `UpdateApplicationRegistrationVariablePayload`, `UpdateApplicationVariableEntityInput`) with `PlaintextString` ### ApplicationVariable always-encrypt uniformization - Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` — all values are now encrypted regardless of `isSecret` - Updated `ApplicationVariableEntityService` to always encrypt on write and always decrypt on read - Simplified `UpdateApplicationVariableActionHandlerService` by removing conditional encrypt/decrypt-on-isSecret-toggle logic - Added slow instance command (`2.9.0`) to backfill-encrypt existing `isSecret=false` plaintext rows and tighten the `CHECK` constraint ### ConfigStorageService refactor - Split `convertAndSecureValue` (which used `any`) into two well-typed methods: `convertAndDecrypt` and `convertAndEncrypt` - Introduced `isSensitiveStringValue` type predicate to narrow values before encryption/decryption ### What's next - Typeorm entity derivation to strictly type sitemap configuration as code + handler logic for encryption rotation - https://github.com/twentyhq/core-team-issues/issues/2465 |
||
|
|
bb4e28904f |
Support the "Me" filter for workspace members in dashboard widgets and add multi select (#20971)
Fixes https://github.com/twentyhq/twenty/issues/20225 The "Me" filter (current workspace member) worked in view filters but not in dashboard widget filters — the server never resolved the placeholder, and the widget side-panel UI had no "Me" option and only allowed single selection. Backend: `ChartDataQueryService` now forwards the current workspace member id (from authContext) into filterValueDependencies, so the shared filter logic resolves "Me" the same way it does for view filters. Added unit tests for the converter. Frontend: new multi-select picker for workspace member filters in the widget side panel, mirroring the view filter's actor select: search input, "Me" pinned item, and a multi-select workspace member list. ## Before <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16 36@2x" src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c" /> ## After <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14 05@2x" src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2" /> |
||
|
|
6ea637d6c5 |
Export STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS (#21010)
fix https://discord.com/channels/1130383047699738754/1509086323464474705 |
||
|
|
531410f64a |
fix(ai): expose MORPH_RELATION join columns in AI/MCP tool schemas (#21012)
## Summary
- Fixes a bug where `noteTarget` (and any other morph-relation join
object) created via AI/MCP would land with `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` left null, even though the tool
reported success.
- Root cause: the Zod schema generators for the AI tools only branched
on `FieldMetadataType.RELATION`. MORPH_RELATION fields fell through to
the default case — for `create_*` they were exposed as `targetCompany:
string` instead of `targetCompanyId: uuid`, and for `group_by_*` they
were silently skipped entirely. Downstream
(`data-arg-processor.service.ts` and the group-by arg processor) already
accept the join-column form for both kinds of relations via
`computeMorphOrRelationFieldJoinColumnName` and
`isMorphOrRelationFlatFieldMetadata`, so the fix is purely in the schema
generators.
## Changes
- `record-properties.zod-schema.ts` — extend the existing RELATION
MANY_TO_ONE / ONE_TO_MANY branches to also match MORPH_RELATION.
- `group-by-tool.zod-schema.ts` — replace the silent MORPH_RELATION skip
with the same treatment as RELATION MANY_TO_ONE (exposes `${name}Id` as
a groupBy option).
- `test/integration/ai/suites/mcp-tool-execution.integration-spec.ts` —
new file. First integration test for tool execution end-to-end. Drives
the real MCP JSON-RPC endpoint with the seeded API key (`learn_tools`
for schema introspection, `execute_tool` for invocation):
- asserts `create_note_target`'s schema exposes `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` as UUIDs and does **not**
expose `targetCompany` / `targetPerson` / `targetOpportunity`.
- creates a company + note + noteTarget via MCP, then queries the
workspace schema to confirm `targetCompanyId` is actually persisted in
the FK column.
- asserts `group_by_note_targets` schema accepts `targetCompanyId` as a
groupBy key.
- sets up 3 noteTargets (2 → company A, 1 → company B), calls
`group_by_note_targets` by `targetCompanyId`, and asserts the counts.
Out of scope: `record-filter.zod-schema.ts` has the same pattern (only
RELATION) — left for a follow-up so this PR stays focused on what was
reported.
## Test plan
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` on changed files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [x] Integration tests pass (4/4) after `database:reset`:
- `should expose the morph-relation join columns as \`${name}Id\` UUID
parameters`
- `should persist targetCompanyId when create_note_target is invoked via
MCP`
- `should expose targetCompanyId as a valid groupBy option`
- `should group noteTargets by targetCompanyId via MCP`
|
||
|
|
6e2cf4cbc0 |
Fix lambda timeout diagnostics (#21002)
Trying to fix https://twenty-v7.sentry.io/issues/7420384466/?environment=prod&environment=prod-eu&project=4507072499810304&query=is%3Aunresolved%20%21issue.type%3A%5Bperformance_consecutive_db_queries%2Cperformance_consecutive_http%2Cperformance_file_io_main_thread%2Cperformance_db_main_thread%2Cperformance_n_plus_one_db_queries%2Cperformance_n_plus_one_api_calls%2Cperformance_p95_endpoint_regression%2Cperformance_slow_db_query%2Cperformance_render_blocking_asset_span%2Cperformance_uncompressed_assets%2Cperformance_http_overhead%2Cperformance_large_http_payload%5D%20timesSeen%3A%3E10&referrer=issue-stream&sort=date ## Summary - Prevent invoking Lambda functions stuck in `Pending` state by checking `Configuration.State === 'Active'` in `checkLambdaExecutorBuildStatus` — a Pending function now goes through `ensureLambdaExecutor` which waits for Active. - Track execution phase (`build`/`fetch-code`/`invoke`) via a `LambdaExecutionPhase` enum and include phase timing + function state in all error messages for faster debugging. Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> |
||
|
|
de7daaa81a |
fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)
## Summary fix: exclude system join objects from AI/MCP create/update/delete tool descriptors Closes #20403 --- AI was used for assistance. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
c0cbe67bcd |
i18n - translations (#20982)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
c5606212f2 |
Ses outbound followup (#20610)
This pull request unifies outbound with inbound under the new feature and the new email groups feature. These are workspace level shared inboxes that are shared between all workspace members. outbound sending with SES works, we only listen for tenant status events, rest is managed by AWS PR refactors old code and webhook to be split for outbound and inbound for proper separation | Area | Change | |---|---| | AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant + identity + DKIM + MAIL FROM + configuration-set + EventBridge dest + contact list) and `AwsSesSendEmailService` (SendEmail). | | Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES → EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS → router → `SesOutboundSendingStateHandlerService` updates `emailing_domain.tenantStatus`. | | Inbound webhook | Refactored into `SesInboundWebhookRouterService` + `SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` + `SnsSubscriptionConfirmerService` across both routes. | | Global uniqueness | New migration + instance command: `emailing_domain.domain` is now globally unique (one tenant per domain across workspaces). | | Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` / `PAUSED`) + `EmailingDomainTenantStatusService`. | | Send-email mutation | New `sendEmailViaDomain` GraphQL mutation + DTOs. | | Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into `WorkspaceService.deleteWorkspace` — tears down SES tenant association + identity on workspace delete. | | Settings UI | Rewritten around reusable `SettingsTableListSection`. "Email Group" → "Email Handle" rename. New cells for status/source/forwarding. Outbound domains surfaced on workspace settings page. | ### Env vars (new) All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional: - `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider` + driver factory - `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory - `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound webhook routers, comma-separated list of accepted SNS topic ARNs (verified via `sns-payload-validator`) ### Migrations - `1778862608620-add-emailing-domain-tenant-status` (fast) — adds `tenantStatus` column. - `1778865501791-unique-emailing-domain-globally` (slow, idempotent) — enforces global uniqueness on `domain`. - Instance commands bumped to `2.5`. ### Infra dependency Two coupled twenty-infra PRs: - `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket policy + KMS grant + `email_group_*` outputs. - `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM policy + outbound `webhook_url` subscription. **Based on `ses-inbound-email`.** Merge order: inbound first, then outbound. Outbound PR's chart edit owns the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs). Features lives under `/settings/general` <img width="1496" height="845" alt="SCR-20260519-ofhi-2" src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d" /> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com> |
||
|
|
f98c5d0609 |
i18n - translations (#20978)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
4797d2f270 |
feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer) |
||
|
|
fbae66de8a |
Fix error when token invalid (#20972)
## Before <img width="914" height="519" alt="image" src="https://github.com/user-attachments/assets/8933bf9e-d8db-4670-ad08-69e900083fc1" /> ## After <img width="1105" height="523" alt="image" src="https://github.com/user-attachments/assets/d8638dbb-adee-4b7d-9d29-1a2a0185ab4f" /> |
||
|
|
46e7f23df1 |
fix(contact-creation): handle common email display-name shapes when auto-creating People (#20639)
## Summary
When messages are imported, Twenty auto-creates a Person record for any
recipient that doesn't exist yet. The display-name parser used at that
point is `displayName.split(' ')[0] / [1]`, which silently mangles
several common header shapes:
| Header | Old result |
|-------------------------------------------------|-----------------------------------------|
| `"Doe, John" <...>` | `firstName="Doe,"`, `lastName="John"` |
| `"John.Doe Doe" <...>` | `firstName="John.Doe"`, `lastName="Doe"`|
| `"Mary Jane Watson" <...>` | `lastName="Jane"` ("Watson" dropped) |
| `"john.doe@x.com" <john.doe@x.com>` (forwarder) | full address in
`firstName` |
| `"Doe, John:GROUP" <...>` (group-tag servers) |
`firstName="John:GROUP"` |
This PR rewrites `getFirstNameAndLastNameFromHandleAndDisplayName` to
handle each pattern. Behaviour in order:
1. Trim + strip wrapping quotes
2. Swap `"Last, First"` comma form
3. Fall back to handle parsing when display name contains `@` (real
names don't)
4. Split single dotted tokens (`"john.doe"` → `"John"`, `"Doe"`)
5. Preserve multi-word last names (`tokens.slice(1).join(' ')`)
6. De-synthesize dot-glued first names (`"John.Doe Doe"` → `"John"`,
`"Doe"`)
7. Strip `:XXX` trailing tag suffix from each parsed field
## Test plan
- [x] 16 new unit test cases covering each shape
(`__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts`)
- [x] Lint + typecheck clean
- [ ] No regression in the messaging import flow
---------
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
|
||
|
|
6f3541fd7c |
i18n - translations (#20975)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
863f3f29a2 |
Fix page layout widget tab moves (#20915)
Fixes widget moves between page layout tabs by making pageLayoutTabId part of the flat-entity diff, so the save mutation no longer silently drops the new tab assignment. https://discord.com/channels/1130383047699738754/1508737039128985680 |
||
|
|
ff28547a36 |
tt-fix-logic-function-error-handling (#20928)
## Summary - **Fix `callWithTimeout` timer leak**: the `setTimeout` was never cleared when the callback resolved first, leaving orphaned timers (up to 15 minutes) in the Node.js event loop. Now uses `try/finally` with `clearTimeout`. - **Properly classify timeout errors**: introduced `ExecutionTimedOutError` so the Lambda driver can throw `LOGIC_FUNCTION_EXECUTION_TIMEOUT` instead of `LOGIC_FUNCTION_EXECUTION_FAILED` — users now see "Function execution timed out" instead of the generic "Function execution failed." ## Test plan - [x] Execute a logic function normally and verify it still works - [x] Trigger a logic function timeout and verify the error message says "Function execution timed out" (not "Function execution failed") - [x] Verify that a deleted logic function invocation logs a specific "was deleted" warning - [x] Verify no orphaned timers remain after logic function execution completes |
||
|
|
986b9dcb3d |
Deprecate dummy enterprise key 1/2 (#20890)
Remove usage of hasValidEnterpriseKey in FE (replaced by hasValidSignedEnterpriseKey) To avoid breaking change at deploy time, we will wait until after this has been deployed in prod, to remove hasValidEnterpriseKey in the BE. |
||
|
|
ae7db23bdd |
i18n - translations (#20959)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
2f358a1775 |
Add a Table display mode to relation field widgets (#20929)
## Context Adds a new Table layout to the FIELD widget for to-many relation fields. On a record page, a relation can now be displayed as a full record table (the same component used for record indexes and dashboard table widgets) scoped to the records related to the current record. https://github.com/user-attachments/assets/320b24dc-f019-4d0e-bc71-3e64d032d75a https://github.com/user-attachments/assets/2f6d4f8e-de26-4fc1-ae12-c9b9c19654dc https://github.com/user-attachments/assets/3fb6d512-f83c-4818-823e-46ad2644fbc2 |