80fb91c033dfd367f17ba58e2095526faa016e70
14255 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80fb91c033 |
Add List View lab icon and make lab feature flag icons mandatory (#23930)
## Context The List View toggle in Settings → Lab rendered without an icon: the front end kept a hardcoded `Partial<Record<FeatureFlagKey, IconComponent>>` map, so nothing caught a public feature flag added without an icon. ## What this PR does Moves the icon into the public feature flag metadata, next to `label` and `description`, as a **required** field — so a lab flag now registers in one place and can't be declared without an icon (server typecheck fails otherwise): - `twenty-server`: `FeatureFlagMetadata` gains required `icon: string`; each `PUBLIC_FEATURE_FLAGS` entry declares its icon (`IconList` for List View — the icon already used for the List view type). `PublicFeatureFlagMetadata` GraphQL entity exposes it. - `twenty-front`: `SettingsLabContent` renders `getIcon(flag.metadata.icon)` via `useIcons`, the same metadata-driven icon pattern used across the app; the hardcoded icon map is deleted. - `twenty-ui`: registers `IconCalendarWeek` in `AllIcons` — it was importable but not resolvable by name through `useIcons`, so it would have silently fallen back to the default icon. - Generated artifacts (`twenty-front/src/generated-metadata`, `twenty-client-sdk/src/metadata/generated`) updated for the new field. ## Verification - `nx run-many -t typecheck,lint -p twenty-front twenty-server twenty-shared twenty-client-sdk twenty-ui` passes (oxlint + oxfmt clean). - Negative case: removing `icon` from a `PUBLIC_FEATURE_FLAGS` entry fails `twenty-server:typecheck` with `TS2741: Property 'icon' is missing … but required in type 'FeatureFlagMetadata'`. |
||
|
|
02a187d065 |
i18n - docs translations (#23924)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
e479d7538d |
i18n - translations (#23922)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
d464065044 |
Add a list view type (#23829)
https://github.com/user-attachments/assets/45c11b5b-8da6-43ee-89de-f3fca0b64038 https://github.com/user-attachments/assets/a1b22a00-f6c8-4074-ae98-00a9a960e62f <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23829?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <huzef@twenty.com> Co-authored-by: Félix Malfait <felix@twenty.com> |
||
|
|
845b48f243 |
Update terms of service (#23906)
## Summary Replaces the terms of service page content, which came from a generic template, with terms written for how Twenty actually works: an open-source project, a hosted cloud service, and paid self-hosted plans. Main changes: - Data section rewritten around workspace privacy: customers own their data, we only use it to run the service, and it leaves the workspace only through explicit sharing or publishing features (shared content, apps that publish pages or API endpoints) - Explicit open-source scope: the software licenses govern self-hosting; these terms cover the site, the cloud service, and paid license keys, with the commercial self-hosted license spelled out (grant, per-user seats with true-up, key verification, expiry behavior) - Sections added that the template lacked: security and DPA reference, connected accounts, AI features, confidentiality, publicity, suspension, mutual indemnification, warranty disclaimer, data retention and export after a subscription ends (aligned with the actual suspension and deletion pipeline: 14-day minimum retention, warning email, reactivation or support-provided export) - Clearer mechanics: 30-day notice for material changes to the terms, price changes only at renewal, refunds when the fault is ours, cancellation effective at the end of the billing period, business and professional use only - Removes template sections that never applied (contests and sweepstakes, shipping information) and trims the marketing-email clause down to transactional emails plus opt-out product updates ## Notes - Content-only change to `TermsDocument.tsx`; no route, styling, or i18n changes (legal pages are not translated) - The effective date in the document is a placeholder to set when this ships - Legal review is planned before merge |
||
|
|
728cc078c8 |
i18n - docs translations (#23919)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23919?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
43f11cdd3a |
Expire webhook subscriptions whose refresh token is dead (#23907)
Follow-up to #23707. Token errors thrown while building the OAuth client never reach the driver-exception mapping, so they landed in `handleUnknownException`: channel marked FAILED, captured to Sentry, rethrown (captured again by the queue explorer). The renewal cron re-selects FAILED channels every tick, so a dead refresh token looped forever. Routes REFRESH_TOKEN_NOT_FOUND and INVALID_REFRESH_TOKEN to the existing expiry path, matching the message and calendar import handlers. Sampled 160 events across [TWENTY-SERVER-J1F](https://twenty-v7.sentry.io/issues/7603992092/) (~5.9k/day) and [TWENTY-SERVER-JBZ](https://twenty-v7.sentry.io/issues/7617075015/) (~1.5k/day): 100% originate here. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23907?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
ff6e5847ab |
i18n - docs translations (#23917)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
72e6710545 |
fix(twenty-front): resolve the command menu item i18n rendering issue (#23913)
The most command menus item labels are `string`. The original logic prevent it form being translated. Additionally, the `i18n._()` can handle both `string` and `MessageDescriptor` type, so it's safe to remove this logic. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23913?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6d4f03505b |
i18n - translations (#23916)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
9e3c3131f7 |
feat(run-agent): let apps run an agent on behalf of a workspace member (#23470)
## Why `runAgent()` always runs with the agent's own role, so an app has no way to scope a run to the person who triggered it. Chat-style apps (Slack, Discord, Teams) need the opposite: the agent should never be able to do more than the member who asked. This is the server/SDK prerequisite for per-user permissions in the Slack app (#22984). It is self-contained and reviewable without any Slack context. > **Scope note.** Review surfaced two authorization problems adjacent to this code that are not part of the original feature — a cross-app agent hole (#21157) and two fail-open branches in application-token auth. Both are fixed here rather than deferred, since they sit directly on the path this PR changes. They are called out separately below so they can be reviewed on their own terms. ## The feature - **`runAsWorkspaceMemberId` (optional) on `RunAgentInput`** — shared type, DTO, and the generated GraphQL artifacts. - **`AgentActorContextService.buildRunAsWorkspaceMemberContext`** resolves member → userWorkspace → role and returns an actor context, a *user* auth context, and the role id. Mirrors what `WorkflowExecutionContextService` already does for acting on behalf of a user. - **`AgentRunService`** swaps the application auth context for the member's, passes their actor context, and attributes AI credit usage to them. - **`buildAgentRolePermissionConfig`** (new util) returns `intersectionOf: [agentRoleId, runAsRoleId]`, agent role first — explicit object grants in `database-tool.provider` resolve against the first role, so it defines which objects are in scope at all and later roles only narrow permissions on them. Collapses to a single entry when the member already holds the agent role, because the permission-flag checks reject an intersection listing the same role twice. - **`ToolContext`** gains an optional `rolePermissionConfig`. The lazy tool path resolved permissions from a single `roleId`, so without this the narrowing would not reach the tool catalog or call-time `execute_tool` — and lazy is the strategy `runAgent` uses. Falls back to the previous `unionOf: [roleId]` default when absent. - Docs: a "Running on behalf of a workspace member" section in `skills-and-agents.mdx`. Omitting the field preserves today's behavior exactly, which is what autonomous runs (scheduled jobs, database-event triggers) need. **Fails closed:** an unresolvable member errors rather than falling back to the agent role, which would grant more than the caller asked for. ## Who may name a member `runAsWorkspaceMemberId` names another person, so it needs an authorization rule of its own. An application token is not sufficient on its own: `frontComponent(id)` is guarded by `UserAuthGuard, NoPermissionGuard` and mints an `APPLICATION_ACCESS` token pair for the requesting user, so any authenticated user can obtain one for an installed app. Those tokens record who they were minted for, and the caller cannot strip that. The rule keys on that binding: | Token | May name | | --- | --- | | No application token | nothing — rejected | | Application token **with** a user binding | only that user's own member | | Application token with **no** user binding | any member | The third row is unattended app code — a database-event-triggered logic function is the Slack worker's path, and `client_credentials` or API-key-minted tokens land here too. ## Adjacent fixes **Cross-app agents** (pre-existing, #21157). The agent lookup was not scoped to the caller, so any app token could run any agent in the workspace, including one belonging to an app with wider permissions — while `skills-and-agents.mdx` promised an app can only run its own. Now rejected with `RUN_AGENT_NOT_ALLOWED`. Guarded on `isDefined(callerApplication)`, so callers without an app token are unaffected; `twenty-front` never calls `runAgent`. **Two fail-open branches in `validateApplicationToken`.** Both populated the auth context conditionally instead of failing closed, and both are now asserted, making the application path structurally identical to `validateAccessToken`: 1. An unresolvable user left the token presenting as *unbound*, so removing someone from a workspace widened their live token instead of revoking it, until it expired. 2. A missing workspace member let the token carry on with the application's own permissions after that member was removed or deactivated. Both mirror `validateAccessToken`, down to its `PENDING_CREATION` / `ONGOING_CREATION` escape hatch. **Behaviour change beyond this PR:** an application token whose user has been removed now 401s where it previously degraded to app-only. That is the point, and it matches access-token semantics, but it is shared auth and worth a careful look. ## Known limitation If the app's own agent role declares row-level predicates, those are not applied in run-as mode, because the query builders resolve row-level rules from a single role via the auth context. The member's own row-level rules do apply, which is the direction that matters here. Multi-role row-level support does not exist anywhere in the codebase today. ## Tests | Check | Result | | --- | --- | | ai-agent-execution, tool-provider, record-crud, user-workspace, full auth tree | 79 suites, 666 passed | | `nx typecheck twenty-server` | clean | | oxlint + oxfmt on the changed server files | clean | Both auth regression tests were verified against the pre-fix code — each fails when the fix is reverted, so they guard the behaviour rather than passing incidentally. ## Note for reviewers Rebased onto `main`, then merged `main` in once more after #23395 landed. The `getObjectsPermissionsFromRolePermissionConfig` intersection fix this PR originally carried has since landed on main independently, and main's version is stricter — it denies when an intersected role is missing from the cache rather than treating it as empty — so this PR takes main's and no longer touches that file. `RunAgentInput` now composes with the `prompt` | `messages` XOR from #23395: `runAsWorkspaceMemberId` sits on the base object, so it is available to both variants. |
||
|
|
569e178dbc |
chore: bump version to 2.30.0 (#23914)
## Summary - Moves current version to previous versions array - Sets TWENTY_CURRENT_VERSION to the new version - Updates TWENTY_NEXT_VERSIONS with the next minor version - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23914?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
838fb90749 |
i18n - translations (#23912)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
7f0bae5b5f |
Add global admin panel chat list with onboarding filter and enriched transcript (#23757)
https://github.com/user-attachments/assets/ee22d0d7-6ea0-4d49-a3d2-41ce19089943 Adds a cross-workspace chat list to the admin panel (admin-panel/chats, linked from the AI tab) so we can analyze onboarding AI chats and improve the workspace-setup prompts. - Filters: onboarding only, has error, no user reply; search by workspace, user email or thread id; server-side sort by message count, replies, created or updated, with pagination. The list opens unfiltered so every chat is visible by default. - Onboarding threads are detected by fingerprint (hidden kickoff message OR deterministic uuid v5 id), so all existing setup chats are covered retroactively. The allowImpersonation gate is enforced in the query. - Replies count answered `ask_questions` cards as well as user messages: answering one writes no message row, only an in-place toolOutput update, so those chats used to look abandoned. - The admin transcript now returns the hidden kickoff prompt (collapsed in the UI) and enriched message parts: reasoning, tool input/output rendered as JSON trees, and errors. Reference chips are not navigable there since they would link into the reader's own workspace. - Fixes the workspace detail "Messages" column which displayed conversationSize (tokens) instead of the message count. |
||
|
|
abd973ab4a |
i18n - translations (#23905)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
4dbaafc65d |
Revert "Make subdomain minimum length configurable via env var" (#23871)
Instead, reduce the subdomain minimum length to 1 char <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23871?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
47f82b9121 |
Gate the workspace setup AI chat to workspace creators (#23881)
The server already refuses to start a workspace setup chat for anyone who isn't the workspace creator (`workspace-setup-chat.service.ts`, via `userWorkspaceService.isWorkspaceCreator`), but nothing on the client checked that. An invitee finishing onboarding was still routed to `/workspace-setup`, where the kickoff mutation returned `UNAVAILABLE` and the effect silently returned — leaving them on a dead-end page with the onboarding header and an empty chat that never starts. Exposes `isWorkspaceCreator` on `User` as a resolve field next to `onboardingStatus`, reusing the existing service method, and gates both the post-onboarding redirect and the page itself on it. Invitees now land on the default home page instead. |
||
|
|
00b6d651f4 |
fix(front): preserve currency decimals when opening the field editor (#23874)
Fixes #23828 ## Problem Opening and closing a currency field editor corrupts the stored amount when the value has more decimals than the field's `decimals` setting (which defaults to 0). No keystroke is needed. - With the `1.234,56` number format, `458.64` is persisted back as `45864`. - With the `1,234.56` number format, `458.64` is persisted back as `458`. ## Root cause The draft amount is serialized with a dot decimal separator (`amountMicros / 1000000).toString()`), and `CurrencyInput` hands that string to the IMask `Number` mask with `scale={decimals}` alongside the workspace `thousandsSeparator` and `radix`. With `scale` 0 and a dot thousands separator, imask reads `458.64` as `45864`; with a dot radix it drops the fraction and yields `458`. That misreading is only half of it. `react-imask` re-emits `accept` while it formats the value it was given, so **merely mounting the editor** pushed the mask's own reading of the amount into `internalText` and into the draft value. From there every exit path persisted it, escape included. That is why the corruption needs no keystroke. ## Fix Two layers, smallest first: 1. `CurrencyInput` ignores `accept` events that carry no originating input event. In imask, `_inputEvent` is set only inside `_onInput` and deleted right after, so a user keystroke (including the reformat emitted within the same turn) always carries it, while mount and programmatic updates never do. The draft can now only change because someone typed. 2. The exit handlers pass `skipPersist` when the resulting value already matches the stored one, so opening and closing a field writes nothing at all - no redundant update, no timeline entry. `getSafeScaleForCurrencyInput` is kept as well: the mask scale is what makes the editor *display and edit* the right number. Without it, a `458.64` amount still opens as `45.864`, and a genuine edit would then build on the wrong base and persist legitimately. ## Tests Unit tests on both utils, plus an imask round-trip of `458.64` with `decimals` 0 across all four number formats, which fails on `main` for both dot-separator formats. Verified in a local instance against the reported case (`458.64`, `decimals` 0): | scenario | result | |---|---| | open + close, `1.234,56` | value unchanged, `updatedAt` untouched (no write at all) | | open + close, `1,234.56` | value unchanged | | type a new amount | persists normally | | type `12.5` on a `decimals` 0 field | yields `125`, separator still rejected | |
||
|
|
10ee130eb9 |
Show only the record title in the mobile page header (#23902)
On mobile there isn't enough room for the full breadcrumb, so the object type and the pagination count were pushed under the action button. ## Changes **`ObjectRecordShowPageBreadcrumb`** — on mobile, hide the object-type prefix (icon + plural label + `/`) and the pagination count. Only the record title remains, still editable and still ellipsized. **`PageCardHeader`** — the header grid was `minmax(0, auto) minmax(0, 1fr)`. The left (breadcrumb) track grew toward its max-content size and consumed the whole row, collapsing the action-button track to 0, which is why the button ended up drawn over the text. It's now `minmax(0, auto) minmax(min-content, 1fr)`, with `min-width: 0` dropped from the right container so its min-content is meaningful: the right column still takes leftover space, but can never shrink below what the buttons need. Note on why the right column isn't just `auto`: the pinned action bar measures its own container width (`NodeDimension`) to decide how many buttons fit inline, so a content-sized track gives it a 0 basis and it collapses to the overflow `⋮` alone. ## Verification Checked in a browser at an iPhone 13 viewport and at 1280px, with a record renamed to a long title to reproduce the overflow. - Mobile before: breadcrumb right edge 24px past the button's left edge, `(1/599)` under the `⋮`. - Mobile after: title only, ellipsized, right edge 8px clear of the button. - Desktop after: unchanged — object type, title, count, and the full action bar (Send Email, favorite, prev/next, `⋮`) all present. Also checked the record index, people index, and settings headers at both widths for horizontal overflow: none. `lint:diff-with-main` and `typecheck` pass on twenty-front. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xh1XGCRzouRroaRyDb7Nfn)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23902?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
4dbc5a567e |
feat(slack): strip the bot's own mention anywhere in the request text (#23837)
## Context `parseSlackAssistantRequest` only stripped a bot mention at the very start of the message. A message like "hey @twenty, who owns ACME?" sent the raw `<@U0123ABC>` token into the agent prompt, where the agent sees an opaque id. **Stacked on #23835**: this PR's base is `feat/slack-no-silent-dead-ends`, since both touch `parse-slack-assistant-request.ts`. Merge #23835 first; this PR then retargets to main with only its own changes. ## What this does Only the bot's own mention may be stripped: mentions of other members are part of the request ("ask <@UALICE> about the ACME deal" must keep `<@UALICE>`), so blindly removing every `<@…>` token is wrong and the parser needs the bot's actual user id. - The Slack `event_callback` envelope already carries that id in its `authorizations` field, so `getSlackBotUserIdFromEventBody` reads it straight from the request body. The parser stays pure and synchronous, no `auth.test` call and no KV involved, and this does not collide with the connect-time id cache #23726 is introducing. - `stripSlackBotMention` removes every occurrence of that mention (leading, mid-text, `<@U…|label>` form), in app_mention events and DMs alike. When the mention sits directly before punctuation, the preceding whitespace is consumed too, so "hey @twenty, who owns ACME?" becomes "hey, who owns ACME?" rather than "hey , who owns ACME?". Everything else is covered by the parser's existing whitespace collapsing. - Fallback when `authorizations` is absent: on app_mention events the bot id is derived from the leading mention and other occurrences of that same id are stripped, which matches the old behaviour on the old inputs. Without any way to identify the bot (a DM without `authorizations`), mentions are left untouched rather than guessed at. - A side effect of knowing the real bot id: a message that *starts* with another member's mention no longer has that mention wrongly stripped as if it were the bot's. ## Tests New parser cases: bot mention mid-text, at start plus mid-text, repeated mention via the leading-mention fallback, other-user mentions preserved next to a stripped bot mention, leading other-user mention preserved, DM containing the bot mention, and DM with an unknown bot id keeping mentions intact. `yarn test:unit` (61 tests), `yarn typecheck` and `yarn lint` all pass in the app. --- _Generated by [Claude Code](https://claude.ai/code/session_01QQUfYsp2j4robY6rZFrixZ)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23837?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
fd85e64fe3 |
slack: answer empty requests and nudge lapsed threads (#23835)
## Context
Two assistant paths ended with no visible reaction for the member:
- a mention with no request text (a bare `@twenty`) or an empty DM was
skipped silently
- a follow-up in a thread whose 24-hour subscription window had lapsed
was dropped silently, with no hint that the bot "forgot" the thread
## What this does
**Empty requests get answered.** `parseSlackAssistantRequest` flags an
empty mention/DM with its channel and thread target, and the enqueue
function answers in-thread. A bare mention that starts a fresh thread
(or an empty DM) gets a short markdown list of example asks; a bare
mention inside an existing thread gets a context-aware one-liner
instead. There is no request record to dedupe on for these, so a KV
claim (`slack-empty-request-reply:{channel}:{ts}`, 1-hour expiry) keeps
Slack event redeliveries from double-posting, and the claim is released
if the hint cannot be posted so a redelivery can retry. The hint's
thread is subscribed like any answered thread, so a follow-up question
there is picked up without a re-mention. Empty messages in unmentioned
thread follow-ups still skip silently on purpose.
**Lapsed threads get a nudge.** `is-slack-thread-active.ts` becomes
`get-slack-thread-subscription-state.ts`, a pure read returning `active`
/ `expired` / `none`: an expired-but-present KV key proves the bot used
to follow that thread, while `none` means it never did, so random
threads stay untouched. On `expired` the author of the follow-up gets an
ephemeral (only they see it) asking them to re-mention the bot. The
lapsed key is cleared only after the nudge actually posted, via
`clearLapsedSlackThreadSubscription`, which re-checks that the
subscription was not renewed in the meantime; a failed nudge is retried
on the next follow-up.
Supporting changes: the `slack-post-ephemeral-message` step gains an
optional `parentMessageTimestamp` (type, schema, handler) so the nudge
can post inside the thread, mirroring `slack-post-message`; the enqueue
result shape is extracted into a shared `SlackEventsEnqueueResult` type;
nullish checks use `isDefined` from `twenty-sdk/utils`. README and
SETUP.md behaviour notes describe the new behaviours.
## Demo
https://github.com/user-attachments/assets/03add964-92fb-46e5-aa49-06faad85e18c
<img width="406" height="531" alt="Screenshot 2026-08-06 at 6 34 57 AM"
src="https://github.com/user-attachments/assets/bc001057-8998-4d1f-ac7e-e1ed8b5f3770"
/>
|
||
|
|
712e5ece7e |
Strip template elements from the HTML block preview (#23866)
Follow-up to a code scanner alert on `HtmlNodeView`. The alert itself is
not a live vulnerability, but auditing the hand-rolled sanitizer behind
it turned up one gap worth closing.
## What I checked
`sanitizeHtmlPreview` is a blocklist sanitizer feeding
`dangerouslySetInnerHTML` in the editor, so I ran ~30 payloads through
the real implementation in headless Chromium, inserting the sanitized
output into a live document. A deliberately unsanitized control payload
fired, so the harness was actually detecting execution.
Nothing executed. The parser-differential classes it already survives:
`noscript`, `noembed`, `noframes`, `xmp`, `listing`, `title` and
`textarea` raw-text handling; `svg`/`math`/`mglyph`/`foreignObject`
namespace confusion; comment breakouts; table foster parenting; `<body
onload>`; `<iframe srcdoc>`; `data:text/html`; and `javascript:` with
entity and control-character obfuscation.
## The gap
`document.body.querySelectorAll('*')` does not descend into a
`<template>`'s content, which lives in a separate document fragment. So
this:
```html
<template>``<img src=x onerror="alert(1)">``</template>
```
came back out of the sanitizer with its handler intact.
It does not execute as currently used — template content is inert when
assigned through `innerHTML`, and I confirmed that. But a live event
handler sitting inside a string the sanitizer just declared clean is a
footgun for anything that later clones, re-parses or forwards it.
The outbound email sanitizer already drops template content, so blocking
the element here also stops the preview from showing something the sent
email would not contain.
## Scope
One word in the blocklist plus tests. Inline styles are deliberately
untouched, since dropping them would make the preview diverge from the
rendered email.
An earlier revision also added `contain: paint` to the preview, to stop
a block from painting over the editor chrome. That was dropped after
testing showed it clips box-shadows bleeding past the block edge and
negative-margin full-bleed layouts, both normal in email design. Details
in the review thread. The overlay it guarded against needs an
authenticated member who can already edit the template, which does not
justify constraining newsletter design.
## Testing
Re-ran the full payload corpus with the new blocklist. The template
payload's handler is gone, every other result is byte-identical, and the
control still fires. The two new unit tests were verified to pass under
jsdom, the environment jest actually uses.
|
||
|
|
8774bf8604 |
Self-host every font instead of loading them from Google (#23859)
Google Fonts logs the IP and user agent of everyone who loads a font from it. Any page of ours that links to `fonts.googleapis.com` hands our users (and every self-hoster's users) to a third party for nothing in return, since we can serve the same bytes ourselves. After this PR there is no reference to `fonts.googleapis.com`, `fonts.gstatic.com` or `next/font/google` left in the repo. ## What changed **twenty-front, PDF export.** `exportBlockNoteEditorToPdf` registered Inter by URL against `fonts.gstatic.com`, so exporting a note made the browser fetch three TTFs from Google. The registration turned out to be unnecessary altogether: `@blocknote/xl-pdf-exporter` already registers an `Inter` family for its PDF schema, shipped inlined in the package as a base64 TTF with the same 2849-codepoint coverage. Deleting our `Font.register` means no font request leaves the browser, with 41 fewer lines and nothing vendored. Only weights 400 and 700 were ever used, and 700 already resolved to blocknote's `Inter18pt-Bold` before this branch, so the custom 500/600 registrations were dead. The only rendering change is body text going from `Inter` to `Inter18pt`, the same typeface at its 18pt optical size. **twenty-sdk, OAuth callback page.** The local "you can close this tab" page linked to Google Fonts, which meant running `twenty auth` phoned Google from the developer's browser. Replaced with a system font stack; a transient callback page did not justify a webfont round trip in the first place. **twenty-ui, Storybook.** `preview-head.html` loaded Inter from Google. It now imports `@fontsource/inter` in `preview.tsx`, matching what twenty-front's Storybook already does. **twenty-website.** Host Grotesk, Aleo, Azeret Mono and VT323 came through `next/font/google`. Next self-hosts those at runtime, so this was not a visitor-facing leak, but the build still had to reach Google, which makes builds non-hermetic and fails in an air-gapped environment. The latin subsets are now vendored in `src/fonts/`, next to the Inter files that were already there, and loaded with `next/font/local`. All four are OFL 1.1; `src/fonts/README.md` records each file's upstream and license. Total added weight is ~78 KB, and these are the exact files Next was downloading at build time anyway. Host Grotesk and Azeret Mono ship as single variable files, so they are declared once over their full `wght` axis rather than as one face per weight. ## Also removed Both Storybooks pulled `iframeResizer.contentWindow.min.js` from `cdnjs.cloudflare.com`. Storybook has not needed it since v7 and nothing in either package references `iframeResizer` or `parentIFrame`, so it was a third-party script executing in the preview iframe for no reason. Argos does not screenshot through the manager iframe either: `@argos-ci/storybook` hooks Vitest browser mode and calls `server.commands.argosScreenshot`, so Playwright drives the page directly. ## Verification Not just typecheck. The interesting parts were tested end to end, which caught two bugs an earlier revision of this PR had introduced. **PDF export** — production Vite build, served over HTTP, real Chromium, exporting through the actual `exportBlockNoteEditorToPdf`, then extracting the PDF's text back out: ``` Latin heading Cyrillic: Привет мир Greek: Ελληνικά κείμενο Latin-ext: Zażółć gęślą jaźń, Český Vietnamese: Tiếng Việt PASS Latin / Cyrillic / Greek / Polish / Czech / Vietnamese ``` Embedded fonts are `Inter18pt-Regular` / `Inter18pt-Bold`, no Helvetica fallback, zero requests off-origin. **Website** — built it, audited the build output (12 `@font-face` rules, all `/_next/static/media/`, weights `300 800` / `100 900` / `300` / `400` / `400,500,600`, `display: swap` preserved), then loaded it in Chromium: 136 requests, zero to Google. The deployed preview was checked too: no Google references in the served HTML or across all 21 CSS chunks, every font file returns `200 font/woff2` and parses to the expected family, and the asset hashes match a local build byte for byte. **Two bugs this caught**, both in earlier commits on this branch, both now fixed: 1. Registering `@fontsource/inter`'s latin file dropped coverage from 2849 codepoints to 230, silently removing Cyrillic, Greek, Vietnamese and extended-Latin from every export. fontsource splits Inter into seven per-script files chosen by `unicode-range`, but `Font.register` binds one file per weight with no equivalent. 2. Any woff2 aborts the export outright with `RangeError: Offset is outside the bounds of the DataView`. fontkit parses woff2, but `@react-pdf`'s subsetter chokes on the transformed `glyf` table. Confirmed format was the only variable by running identical content through local TTF, WOFF and WOFF2 files. Both are moot now that the registration is gone, but they are why this is worth a careful look rather than a rubber stamp. ## Left alone, but worth knowing about More third-party calls exist. None are font-related and each is a separate decision: - `twenty-website` loads `dotlottie-player.wasm` from **unpkg.com** at runtime on the homepage, via `@lottiefiles/dotlottie-react`. This is a live third-party CDN request on every visit, the same class of problem as the fonts, and looks like a small config change to self-host. - The halftone studio loads the Draco decoder from `www.gstatic.com` and, in exported scenes, three.js from `unpkg.com`. - The partners marketplace fixtures hotlink logos from `cdn.simpleicons.org` and `upload.wikimedia.org`. - reCAPTCHA and the Front support chat are config-gated and off unless an admin configures them, which seems right. - `APP_REGISTRY_CDN_URL` defaults to `https://unpkg.com`. - `twenty-front/index.html` points its `og:image` at `raw.githubusercontent.com`. Only social crawlers fetch it, so this is cosmetic. |
||
|
|
435bb54b97 |
i18n - docs translations (#23878)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
3cbcf999d6 |
Add call recording transcript and summary widget types (#23864)
Registers two record-page widget types for call recordings: CALL_RECORDING_SUMMARY and CALL_RECORDING_TRANSCRIPT. - Widget type and configuration type enums, configuration DTOs, and creation/update validators - Core pageLayoutWidget_type_enum migration (2.29 fast instance command) - Shared configuration types and regenerated metadata client schemas Widget components and their placement on the callRecording record page layout come in a follow-up PR. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23864?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a47f566eb1 |
fix(twenty-server): notify SSE subscribers when an update leaves their filtered view (#23858)
## Context Record tables subscribe to SSE with their query signature (object + filter) and the server pushes only matching DB events. `isQueryMatchingObjectRecordEvent` evaluated `after ?? before` — for UPDATED events that is always `after`, so an update moving a record **out** of a filtered view never matched: no event, and the open table keeps the stale row until a manual reload. Symptom on twenty-internal: a Sales Action Item marked Done by an AI-chat tool (or any API/workflow write) stays visible in the `status = OPEN` view. Records *entering* a view appear live; records *leaving* never disappear. UI edits mask the bug via the local Apollo cache. ## Fix For UPDATED events, view membership now matches on either snapshot (a before-only match = the record just left the view). Row-level authorization is unchanged: still evaluated against the delivered snapshot, so a before-state match cannot authorize a payload the subscriber lost RLS access to. A pre-existing, unrelated payload leak spotted during review (before values delivered when a record enters RLS scope) is fixed separately in the stacked #23870. ## Test plan - Unit: leave-view update publishes (fails on the old matcher — verified), neither-state-matches does not, RLS-failing delivered state does not even when before matched. 36/36 on the spec, full server suite green. - End-to-end on a local stack: companies table filtered `Name contains 'Open'`, `updateCompany` renamed a row out of the filter via the API → row disappeared from the open table within seconds, no reload. |
||
|
|
f5a42cdbae |
Give AWS SDK clients an explicit request timeout (#23857)
## Why The AWS SDK defaults `requestTimeout` to `0`, which means *no timeout* — see `DEFAULT_REQUEST_TIMEOUT` in `@smithy/node-http-handler`. None of our clients overrode it, so a request that never completes holds its socket forever. Once that happens to `maxSockets` requests (default 50), the client's connection pool is permanently exhausted and every subsequent call on that process queues indefinitely rather than failing. To the caller it is indistinguishable from a hang. This caused a production incident on 2026-08-06. A single `twenty-server` pod reached: ``` @smithy/node-http-handler:WARN - socket usage at capacity=50 and 1004 additional requests are enqueued. ``` and never recovered — the queue grew monotonically and the pod completed **zero** Lambda invocations over 12 hours while its six siblings completed dozens each. Egress was fine (a direct HTTPS call to the Lambda API returned in 53ms) and the pod was never OOM-killed or restarted, so nothing surfaced as an error anywhere. The user-visible effect was that workspace creation hung. Activation reached `synchronizeTwentyStandardApplicationOrThrow`, blocked on a Lambda call, and never returned or threw — so the `catch` in `activateWorkspace` that resets a workspace to `PENDING_CREATION` never ran, and the workspace was stranded in `ONGOING_CREATION`. Retrying didn't help because the pod was still poisoned. With one bad pod out of seven, roughly one signup in seven failed: | pod | activations started | completed | |---|---|---| | healthy × 5 | 13 | 13 | | poisoned | 3 | **0** | Nothing appeared in Sentry, because nothing ever threw. ## What this changes - **Every AWS SDK client now sets `connectionTimeout` and `requestTimeout`** via a shared `buildAwsRequestHandlerOptions()` helper. A saturated pool now surfaces as an ordinary error the caller can catch and retry instead of hanging forever. This is the fix that matters. - **The Lambda client gets a higher ceiling and a larger socket pool.** Synchronous invocations legitimately hold a socket for as long as the function runs, so its `requestTimeout` clears `EXECUTOR_LAMBDA_TIMEOUT_SECONDS` (900s) with a minute to spare, and `maxSockets` goes to 200 so long invocations cannot starve the control-plane calls (layer lookups, waiters) sharing that client. - **`S3Client` and `STSClient` in `LambdaAwsClientService` are now reused.** Both were constructed on every call and never destroyed, so each leaked its own agent and socket pool. They are invalidated alongside `lambdaClient` when assume-role credentials refresh. No new dependency: `requestHandler` already accepts a plain `NodeHttpHandlerOptions` object. ## Deliberately not in scope - **A timeout around `activateWorkspace` itself.** A hung activation still strands a workspace in `ONGOING_CREATION` until the 5-minute stale-lock reclaim, and that only fires if the user happens to retry. Worth fixing separately. - **Alerting on `socket usage at capacity`.** That warning was the only signal this was happening and nobody was watching it — an infra change rather than a code one. ## Testing - Unit tests for the helper, including that the timeout is always non-zero. - `tsc --noEmit` clean on the touched files; `oxlint` reports 0 warnings and 0 errors. - Not reproducible in a test environment — the leak needs a saturated pool — so the mechanism above is evidenced from production logs and the SDK's own defaults rather than from a regression test. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23857?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
577cd68cb5 |
feat(slack): release the Slack team claim when the connection goes away (#23753)
Closes the loop on https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334, where the Slack app claimed a `team_id` on connect and never released it. The `onDisconnect` hook it waited on merged in #23538, and `twenty-sdk` 2.27.0 ships the `onDisconnectLogicFunction` manifest field, so this bumps the app to it. ## Disconnect `slack-team-release` releases the server-scoped `slack-team:{teamId}` claim, so another Twenty workspace can connect that Slack team afterwards. `kv.delete` on a SERVER key only clears a claim the calling workspace owns, and the result reports a team only when that delete actually removed something. The connection is already deleted when the hook runs, so `getConnection` cannot resolve the `team_id` anymore. `claimSlackTeam` records `teamId` under a workspace-scoped `slack-connected-account-team:{connectedAccountId}` key, written before the claim itself so a failure between the two cannot leave a claim nothing can resolve, and the release reads it back from there. That also retires the TODO the review comment pointed at. The release is skipped when a live connection still maps to the same team, excluding the disconnecting account explicitly. That covers a reconnect that re-claimed the team while the job was queued, and a second connection to the same Slack workspace, whose claim would otherwise have been dropped along with the first. The recorded team is cleared either way, so the disconnecting account leaves nothing behind. ## Uninstall Uninstalling the app drops its connections through the `connectionProvider` and `application` cascades, which never reach the disconnect hook, and the same operation deletes the app's logic functions so the hook could not run anyway. The app declares an `uninstallLogicFunction`: it runs before the app's metadata is deleted, lists the remaining Slack connections, and releases each claim through the same `releaseSlackTeamClaim` util. It deliberately skips the live-connection check, since at that point every connection is still listed. ## Known limits A disconnect immediately followed by a reconnect has a window where the queued release can clear a claim the reconnect just took. It self-heals, because the reconnect's own onConnect re-claims the same key moments later. Closing it properly needs a compare-and-set on the claim, which the key-value store does not expose. No unit tests are added for the release path. Earlier revisions of this branch had specs for the disconnect guard, the uninstall sweep and the no-op-release result; they were dropped, so those branches carry no regression coverage. The app's existing 34 tests still pass, along with lint, typecheck and `twenty dev:build`, the last being what used to fail with `Duplicate universal identifiers` on the older SDK. |
||
|
|
5f9419589e |
i18n - translations (#23873)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
a7324252fd |
fix(twenty-server): stop the yarn-install Lambda from running out of memory (#23805)
## Context Sentry issue [7438578272](https://twenty-v7.sentry.io/issues/7438578272/) (Logic Function Layer Build Failed, 7.9K events over 3 months): the yarn-install tool Lambda dies with `Runtime.OutOfMemory` / `signal: killed` while building an application's dependency layer. Every layer build for the affected application fails permanently, each database-event trigger re-attempts it, and one workspace produced ~2.5K events in the last week alone. ## Root cause The offending application declares `twenty-ui@1.0.0-alpha.0` (181MB unpacked, dragging in 141MB of `@tabler/icons*`) and dev tooling as production `dependencies` of server-side logic functions. Installing that tree needs just under 3GB during Yarn 4's fetch/link phase, so the 1024MB sandbox is OOM-killed. And even a successful install could never ship: AWS caps a function plus all its layers at 250MB unzipped. The user never sees any of this: the OOM is retried forever, and nothing tells them their dependencies are the problem. ## Fix 1. **Raise the yarn-install Lambda to 4096MB** so legitimate dependency trees install. Tool function names now include the memory/timeout/ephemeral-storage constants in their content hash, so a config change rotates the function name and the ensure path creates a fresh function with the new configuration — without this, the constant change would never reach already-deployed functions (their config is only applied at creation, and the ensure path early-returns when the function exists). 2. **Propagate Lambda's own errors to the user.** The install OOM (`Runtime.OutOfMemory` on the invoke) and the layer size rejection (`InvalidParameterValueException` at `PublishLayerVersion`) map to a new `LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED` code telling the user to move packages their logic functions don't import out of `dependencies`. Surfacing per API boundary: - **Sync / install (CLI)**: the workspace migration interceptor formats it into the same metadata validation error shape the SDK already renders, as one `logicFunction` entry carrying the remedy and the underlying AWS detail — no SDK rendering changes needed. - **`executeOneLogicFunction`**: mapped to `UserInputError` in the GraphQL handler. - **Route triggers**: HTTP 422 with the user-facing message, no Sentry capture. - **Background triggers**: skip instead of retrying, since no retry can succeed until the user changes their application. ## Test The error originates in AWS behavior, which CI (local driver, no AWS) cannot reproduce — so the chain is verified link by link: - **Real AWS, manual (not in CI)**: reproduced with the offending application's actual package.json against real Lambdas in the dev account — OOM-killed at 1024MB and 2048MB (exact prod error signature), install succeeds at 4096MB (~4min), and the resulting 292MB layer is rejected by `PublishLayerVersion` with the exact `InvalidParameterValueException` this PR matches. Same matrix reproduced in local cgroups beforehand. - **Server unit specs**: AWS error payload → exception mapping (`build-yarn-install-failure-exception`), exception → validation payload formatting (interceptor handler), `executeOneLogicFunction` GraphQL mapping, route filter 422 mapping, tool-function/layer name hashing. - **SDK integration spec (mocked server)**: runs the real `app dev` orchestrator on the minimal app with `syncApplication` mocked to return the validation-shaped failure, and asserts the CLI report renders the error code and remedy. It covers CLI rendering only — no test installs actual oversized dependencies, by design. - Docs updated (dependency size limits, sync failure taxonomy, route platform error responses). |
||
|
|
c7cfd143c7 |
fix(twenty-front): keep auto-select model preselection instead of discarding it (#23854)
## Context Opening the Ask AI panel with a FAST model preselection (`useOpenAskAiPageWithPreprompt`) sets the chat's model to the workspace's `fastModel`. For every workspace on default settings that value is the auto-select sentinel `default-fast-model`. ## Bug `useAgentChatModelId` validates the selected model against the enabled-models list — and `useWorkspaceAiModelAvailability` deliberately filters sentinel ids out of that list. The preselection is therefore silently discarded (`selectedModelId = null`), the request is sent with no model, and the server falls back to its default — the **smart** model. Net effect: FAST preselection no-ops on default-configured workspaces (observed on twenty-internal: a `model: 'FAST'` entry point ran on gpt-5.6-sol instead of gpt-5.6-luna). ## Fix Treat auto-select sentinel ids as always available in the check — the server-side registry already resolves them (`getEffectiveModelConfig` → `getDefaultSpeedModel`). One line + a regression test. ## Test `useAgentChatModelId.test.tsx`: new case asserting a sentinel selection survives to `modelIdForRequest`; all 4 pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23854?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
b2684ce107 |
Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869)
Deselecting a to-one relation in a Create Record / Upsert Record step
stored `{ "id": null }` instead of removing the field from the step
input. The step form then renders the field as empty, so nothing signals
that a value is still there, and the run fails later with:
```
Relation "idOpportunity" requires connect or disconnect operation
```
### Why
The relation picker fires `onChange(null)` when the current selection is
dropped (`FormSingleRecordPicker`, "No record" entry).
`handleFieldChange` wraps every to-one relation value as `{ id: value
}`, so `null` became `{ id: null }` and got persisted in `objectRecord`.
At runtime that shape is not a legacy `{ id: "<uuid>" }`, so it is left
untouched by `formatWorkflowRecordRelationFields` and reaches the common
API data arg processor, which rejects any relation value that is not a
`connect`/`disconnect` operation.
The field also reads as empty afterwards (`formData[field]?.id` is
`null`), so the poisoned state is indistinguishable from a clean one in
the UI, and the ✕ that would have cleared it properly is not rendered.
### Fix
Treat a cleared to-one relation as a field removal in both record forms,
matching what the chip's ✕ (`handleFieldClear`) already does. The logic
lives in `buildUpdatedRecordActionFormData`, shared by both components
along with the `RecordActionFormData` / `RelationManyToOneField` types
they each declared separately.
Update Record is unaffected: it stores relations under the join column
(`pointOfContactId`) with a raw value, where `null` is a valid
disconnect.
### Test
Manually, on a Companies Create Record and Create or Update Record step,
using the `Account Owner` relation: pick a record, then pick "No record"
in the same dropdown, and read the persisted step from
`workflowVersion.steps`.
| | `objectRecord` after "No record" |
|---|---|
| before | `{"accountOwner": {"id": null}}` |
| after | `{}` |
Selecting a record still stores `{"accountOwner": {"id": "<uuid>"}}`.
|
||
|
|
8699766303 |
chore: bump version to 2.29.0 (#23820)
## Summary - Moves current version to previous versions array - Sets TWENTY_CURRENT_VERSION to the new version - Updates TWENTY_NEXT_VERSIONS with the next minor version - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23820?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
c7f443662f |
Escape JSON-LD payloads before inlining them in a script tag (#23865)
Fixes the one code scanner alert of the three that turned out to be a
real vulnerability.
## The problem
`JsonLd` inlined `JSON.stringify` output straight into a `<script>`
body:
```tsx
<script dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} type="application/ld+json" />
```
The HTML parser scans a script body for `</script` and `<!--` before any
JSON parsing happens, so a string value containing `</script>` closes
the tag and everything after it is parsed as markup. `JSON.stringify`
does not escape it.
## Why it is reachable
The breadcrumb payloads are not all static. Two values come from outside
the repo:
- `app.name` on `/apps/[slug]` — served by the marketplace API, which
syncs it from the `displayName` field of an npm package manifest
(`marketplace-catalog-sync.service.ts`).
- `partner.name` on `/partners/profile/[slug]` — served by the partners
API from partner-submitted profiles.
Listing and vetting gate both, but that is a human review step, not an
escaping control. There is no CSP backstop either: `next.config.ts` only
sets `frame-ancestors 'none'`, no `script-src`.
Everywhere else these names render as React text and are escaped. This
was the only raw sink in `twenty-website`.
## Verification
Rendered the exact markup the component emits in headless Chromium with
a name of `Evil App</script>``<img src=x onerror=...>'``:
- before: the `ld+json` block is terminated early, an `<img>` element is
created, and the handler runs (page title changes).
- after: the script block stays intact, no element is created, and
`JSON.parse` of the payload deep-equals the input.
## The fix
Escape `<`, `>` and `&` as JSON unicode sequences (`<` and friends).
They parse back to the identical string, so consumers see unchanged
structured data, but nothing in the payload can start a tag or a
comment.
U+2028/U+2029 are deliberately not escaped: they matter when a payload
lands in a JavaScript context, and this one is parsed as JSON. Leaving
them out keeps the source pure ASCII rather than carrying invisible
separators.
Covered by unit tests for the breakout attempt, the comment-opening
case, round-trip equality, and the untouched-payload case.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018sRaaxTucSdufjk6txdQE9)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23865?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
f4d5500fc4 |
fix(front): run a single Monaco instance across the app (#23855)
## Problem Three Sentry issues, all `Missing requestHandler or method: <method>`, first seen in v2.27.0: | Method | Page | Events | |---|---|---| | `findDocumentColors` | `/settings/mcp-apis` | 170 | | `resetSchema` | `/settings/mcp-apis` | 22 | | `getCodeFixesAtPosition` | `/object/workflow/…` | 4 | The first two are Monaco **JSON worker** methods, the third a **TypeScript worker** method. All of them bottom out in `monaco-editor/esm/vs/editor/common/services/editorSimpleWorker.js` at `$fmr` — the foreign-module dispatcher — with `_foreignModule` still `null`. ## Root cause Two Monaco copies end up on the page at **different versions**: 1. **ESM `monaco-editor@0.52.2`**, bundled by Vite — what GraphiQL 5 uses. 2. **AMD `monaco-editor@0.55.1` from jsDelivr** — `@monaco-editor/react` → `@monaco-editor/loader@1.7.0`, whose default CDN path is hardcoded to `monaco-editor@0.55.1/min/vs`. Nothing calls `loader.config({ monaco })`, so it goes to the CDN. `setupGraphiqlMonacoWorkers.ts` assigns **`globalThis.MonacoEnvironment`**, a single global both instances read, as a module side effect of the lazily-routed `GraphQLPlayground`. So once the playground has been opened, the 0.55.1 CDN instance stops using its own AMD workers and starts getting Vite-bundled 0.52.2 ones. The two versions don't share a worker protocol: 0.52 routes language-service calls through `$loadForeignModule` + `$fmr`, which 0.55's client never sends. `_foreignModule` stays `null`, and every call rejects. On `/settings/mcp-apis` the consumer is `SettingsMcpSetup.tsx` — `<CodeEditor language="json">` for the MCP config. Monaco fires `resetSchema` on `onWillDisposeModel` / `onDidChangeModelLanguage` and `findDocumentColors` continuously, which is why one bug produces 170 events and 22. There is a second, independent bug in the same file: the `switch` only maps `json` and `graphql`, so `typescript` / `javascript` / `css` / `html` fall through to the bare `EditorWorker`, which carries no language service at all. That's the workflow-page `getCodeFixesAtPosition`, and it would break even with matching versions. Impact is worse than the log noise suggests: after visiting the playground, JSON validation/colors in the MCP config editor and TS intellisense/quick-fixes in the workflow code editor silently stop working for the rest of the session. ## Changes - **`twenty-ui/src/input/CodeEditor/CodeEditor.tsx`** — configure `@monaco-editor/react` with the bundled Monaco (`loader.config({ monaco })`) instead of letting it fetch its own from jsDelivr. The import stays dynamic so Monaco is still only downloaded when an editor actually renders; the component shows its existing `Loader` until the loader is configured. - **`twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts`** (new, replaces `settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts`) — app-level worker factory mapping every label Monaco can ask for: `json`, `css`/`scss`/`less`, `html`/`handlebars`/`razor`, `typescript`/`javascript`, `graphql`, and the generic editor worker as the fallback. - **`twenty-front/src/index.tsx`** and **`.storybook/preview.tsx`** — set it up once for the app and for stories, rather than as a side effect of one lazy route. Dropping the CDN also means the code editors work in self-hosted and air-gapped deployments, which today silently fall back to a broken editor when jsDelivr is unreachable. ## Verification - `nx build twenty-front` passes; `css.worker`, `html.worker` and `ts.worker` chunks are now emitted alongside the existing `editor`/`json`/`graphql` ones. - Monaco stays lazy — `edcore.main` is not statically imported by the entry chunk and is absent from `index.html`'s modulepreloads. Measured against a baseline build of `main`, the entry chunk goes from 2,598,088 B to 2,599,040 B (**+952 B**). - `oxlint` and `oxfmt --check` clean on all touched files; `tsc --noEmit` clean for `twenty-ui` and reports nothing new for the touched `twenty-front` files. Not verified in a browser — worth a manual pass on the playground → MCP tab → workflow code editor sequence that reproduced the original errors. Fixes TWENTY-FRONT-8YV Fixes TWENTY-FRONT-8YW Fixes TWENTY-FRONT-ADE --- _Generated by [Claude Code](https://claude.ai/code/session_01RgPXkmUHANwD7ooUMqNYr9)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23855?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
829ef9d8b9 |
Revert AI chat chips to the [[kind:...:label]] syntax (#23852)
Removes the `[[kind:...:label[[/kind]]` closing-tag syntax and goes back
to the simpler `[[kind:...:label]]` form for all four chip kinds
(record, object, field, view).
The parser is now a single regex pass instead of a two-pass scan with a
per-reference closing-tag search, a legacy fallback and surplus-bracket
handling. That removes 11 files. The label pattern excludes `[`, `]` and
newlines, which is what keeps an unclosed marker from swallowing the
text (and the marker) that follows it.
```mermaid
flowchart LR
subgraph before ["Before — two passes"]
O1["scan for marker openings"] --> O2["window each opening<br/>up to the next one"]
O2 --> O3["find that kind's closing tag<br/>inside the window"]
O3 --> O4["record only:<br/>bare-terminator fallback"]
O4 --> O5["consume surplus<br/>closing brackets"]
end
subgraph after ["After — one pass"]
N1["matchAll, one regex:<br/>object · field · view · record"] --> N2["map each match<br/>to a chip"]
end
before -.->|"11 files deleted"| after
```
Two things to know:
- Messages already stored with closing tags render as raw text instead
of chips.
- Malformed model output is no longer compensated for: a surplus `]`
after a chip stays in the text, and a display name containing brackets
does not chip. The system prompt tells the model to avoid both.
Rendering cost is unchanged for normal messages and noticeably lower on
long bracket runs, since the old opening pattern had to scan them.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23852?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
18a5121ab2 |
i18n - translations (#23860)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
d8b494d530 |
Make subdomain minimum length configurable via env var (#23209)
## What Introduces a `SUBDOMAIN_MIN_LENGTH` environment variable (default `3`) controlling the minimum number of characters allowed for a workspace subdomain. Until now the minimum was hardcoded (`3`), baked into the shared `SUBDOMAIN_PATTERN` regex. ## How - Added the `SUBDOMAIN_MIN_LENGTH` config variable (default `3`) in `config-variables.ts`. - Relaxed `SUBDOMAIN_PATTERN` in `twenty-shared` to validate format and max length only, so the minimum length policy now lives with the caller instead of being embedded in the regex. - `isSubdomainValid` now takes a `minLength` argument (defaulting to `3`) and enforces it explicitly. - `SubdomainManagerService` reads `SUBDOMAIN_MIN_LENGTH` from config and passes it to every validation call, making the server the authoritative source. ## Scope Server-side only. The frontend validation schema keeps its default `.min(3)` UX check and is unchanged; the server remains the source of truth for what subdomains are accepted. ## Tests - Updated the shared `isValidTwentySubdomain` tests to reflect that the pattern no longer enforces a minimum length. - Added tests for the configurable minimum in `is-subdomain-valid.util.spec.ts`. - Updated the service spec config mock to return a numeric value for `SUBDOMAIN_MIN_LENGTH`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23209?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d4c3759c70 |
ci(pr-review): stop a skipped label dispatch from cancelling the open dispatch (#23856)
## Problem The standard review silently does not run on PRs that get labelled by a bot right after opening. https://github.com/twentyhq/twenty/pull/23854 is an example: no `PR Review #23854` run exists in `ci-privileged` at all. | time | what | |---|---| | 10:08:35 | PR opened | | 10:08:39 | `twenty-eng-sync[bot]` adds the `-PR: draft` label | | 10:08:40 | dispatch run for `opened` starts, cancelled during "Set up job" | | 10:08:43 | dispatch run for `labeled` is skipped by the job `if` | Concurrency is evaluated before the job-level `if`, so the `labeled` run preempts and cancels the in-flight `opened` run and is then skipped itself (`-PR: draft` does not start with `pr-review-`). The sync bot labels within ~4 seconds of open, which is faster than the app-token mint step, so the `opened` dispatch loses this race essentially every time that label is applied. `opened` is the only event that resolves to the `standard` check, so with no later push the PR gets no review at all. Same class of gap as the one #23708 closed, moved down a layer: the trigger exists now but gets cancelled. ## Fix Scope the concurrency group by event action, and only cancel in-progress runs for `synchronize`. Rapid consecutive pushes still de-duplicate; `opened`, `ready_for_review` and `labeled` no longer cancel each other. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23856?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d649baa3f0 |
i18n - translations (#23853)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23853?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
0bf4b53af3 |
feat(slack): cache the Slack bot user id at connect time (#23726)
## What Caches the Slack bot user id in workspace kv so the channel welcome stops calling `auth.test` on every channel-join event. ## Why Slack fires `member_joined_channel` for **every** person joining **any** channel the bot sits in, not just for the bot itself. The welcome path had to answer "was that our bot?", and did it by resolving the Slack connection and calling `auth.test` — a connection lookup plus an external API call, on every event, to conclude "no, that was a human, do nothing". The bot user id never changes for a given connection, so asking Slack repeatedly is the wrong shape. ## How `registerSlackConnection` already calls `auth.test` in the `onConnect` hook and had `user_id` in hand, so it now writes it to workspace kv. `resolveSlackBotUserId` reads it, falling back to `auth.test` (and backfilling) for connections created before this change. Reconnecting is the only thing that can change the bot user id, and reconnecting re-runs that hook — so the cache is self-correcting and needs no TTL. Because resolving the id no longer needs a Slack client, the bot check moved ahead of the connection lookup: | per join event | before | after | |---|---|---| | Twenty round trips | 1 | 1 | | Slack API calls | 1 | 0 | A secondary win: previously `getSlackClient()` ran before the bot check and threw on failure, so a revoked Slack connection made **every unrelated human join** fail its job and retry. Now a human join answers from kv and returns cleanly; the connection is only touched when there is genuinely something to post. ## Claim ordering Moving the client lookup after the claim opened a window where the claim is held but nothing was posted, so that path now releases the claim before throwing. The invariant the file follows is unchanged: release on any failure that produced no message, keep it once a message is out (a retry must not repost the channel message). ## Renames `claimSlackTeam` → `registerSlackConnection`, and the logic function `slack-team-claim` → `slack-register-connection`, since it now does more than claim the team and connect-time work will keep landing there. **The universal identifier value is unchanged** (`a29ae15d-…`) — it is the app's stable identity and what the connection provider binds `onConnectLogicFunction` to. Only the constant's name moved. Worth a second pair of eyes in review, since that is exactly the kind of thing a rename sweep regenerates by reflex. Note this changes `name` and `sourceHandlerPath`/`builtHandlerPath` in the manifest. Both are updates keyed on the unchanged identifier, not a delete-and-recreate, so installed apps re-sync cleanly — but the app needs rebuilding so the bundle path matches. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23722?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> ## Cache correctness A wrong cached id fails silently — the bot's own join reads as someone else's and the welcome never fires — so the entry is bounded and self-healing in three ways: - **Failed write drops the key.** Leaving the previous id in place would keep a superseded value authoritative. An absent cache is rebuilt from `auth.test`; a wrong one is believed. - **Entries expire after 7 days.** `registerSlackConnection` rewrites on every connect, so the expiry only matters when that write never lands. - **A kv outage falls through to `auth.test`** rather than throwing, which keeps the human joins that make up nearly all these events from failing their job. |
||
|
|
5278a47b55 |
refactor(front): move workflow run step logs into workflow-actions (#23841)
Follow-up on unapplied review feedback from #21142. ### Folder structure @thomtrp: > We now have a folder workflow-run/observability, but we have workflow-run related components in workflow-actions folder already. I would avoid that workflow-run/observability folder. The `workflow-run/` folder held nothing but `observability/`, so it is removed entirely and each step log detail component now sits with its action: | File | New location | | --- | --- | | `WorkflowRunStepLogsAiAgentDetail.tsx`, `WorkflowRunStepLogsToolCallRow.tsx` | `workflow-actions/ai-agent-action/components/` | | `WorkflowRunStepLogsCodeDetail.tsx` | `workflow-actions/code-action/components/` | | `WorkflowRunStepLogsHttpRequestDetail.tsx` | `workflow-actions/http-request-action/components/` | | `WorkflowRunStepLogsDetail.tsx`, `WorkflowRunStepLogsEntries.tsx`, `WorkflowRunStepLogsEmailDetail.tsx`, `workflowRunStepLogsStyles.ts` | `workflow-actions/components/` | | `formatDuration.ts`, `formatBytes.ts` | `workflow-actions/utils/` | The email detail stays in the shared `components/` folder since there is no dedicated send-email action folder on the front end (email editing lives at the root as `WorkflowEditActionEmailBase.tsx`). `workflowRunStepLogsStyles.ts` goes next to the shared components rather than `utils/`, since it is styled components and not utils. Also folds in @FelixMalfait's `2 export in 1 file` comment: `workflowRunStepLogsFormatters.ts` is split into `formatDuration.ts` and `formatBytes.ts`. ### AI comments @thomtrp: > same, let's not keep AI comments Removes every comment #21142 introduced, across the front end, server and shared packages: - `WorkflowRunStepLogsEntries.tsx` - the `onlyLatestIteration` prop block - `workflow-run-step-log-schema.ts` - the transport-failure note and the permissive-schema rationale - `strip-ansi-escapes.util.spec.ts`, `build-http-request-step-log.util.spec.ts`, `truncate-string-to-utf8-byte-budget.spec.ts`, `agent-async-executor.service.spec.ts` - the byte-vs-char and pre-fix-behaviour commentary No behaviour change. Locale catalogs are deliberately left untouched; the next i18n run picks up the new source paths. ### Still open from #21142, not covered here - `persistStepLog` try/catch duplicated across the code, tool-backed and ai-agent actions - `draft-email-tool` returning both `sanitizedHtmlBody` and `plainTextBody` - storing both `totalCostInDollars` and `creditsUsedMicro` - the byte-budget truncation utilities being over-engineered - `strip-ansi-escapes` being local to application logs |
||
|
|
f3bc2325cb |
i18n - docs translations (#23851)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
1267697b2c |
Fix missing border below the last record table row (#23846)
The bottom border of the last row of a record table is missing, except under the sticky first columns. <img width="600" alt="before" src="https://github.com/user-attachments/assets/placeholder" /> ## Cause Two things combine. **A 1px off-by-one in the virtualization grid.** Virtualized rows are absolutely positioned on a grid whose pitch is `RECORD_TABLE_ROW_HEIGHT + 1` (row plus its bottom border), and `RecordTableRowVirtualizedContainer` reserves the first slot for the header: ```ts const pixelsFromTop = realIndexByVirtualIndex * (RECORD_TABLE_ROW_HEIGHT + 1) + (RECORD_TABLE_ROW_HEIGHT + 1); ``` `RecordTableVirtualizedBodyPlaceholder` reserves `n * (RECORD_TABLE_ROW_HEIGHT + 1)` of in-flow height to match. But header cells are sized `height: RECORD_TABLE_ROW_HEIGHT` with their `border-bottom` inside that box, so the header only occupies 32px, not the 33px the grid assumes. Everything after the placeholder therefore sits one pixel above the grid. **The add-new row started painting over that pixel.** It used to be an unpositioned sibling, so the absolutely positioned rows painted above it (positioned descendants paint after in-flow blocks) and the overlap was invisible. #23211 wrapped it in `DragDropItemEndDropZone`, which is `position: relative`; #23752 kept that as `StyledEndDropZone`. It is now a positioned element later in DOM order, so it paints over the rows and its opaque background covers the last row's border. The border survives only where cells carry their own `z-index` — the sticky first columns. Measured on `/objects/workflows` with 2 records, before the fix: | element | top | bottom | | --- | --- | --- | | header row | 88 | 120 (height 32) | | last row container | 154 | 187 | | add-new wrapper | 186 | 218 | ## Fix Give the header container the full row slot (`RECORD_TABLE_ROW_HEIGHT + 1`) so the body lines up with the grid the virtualization already assumes. Header cells keep their own 32px sizing, so their internal layout is unchanged. This also closes the 1px gap that previously sat between the header and the first row. After the fix, on the same view: | element | top | bottom | | --- | --- | --- | | header row | 88 | 121 (height 33) | | first row | 121 | 154 | | last row | 154 | 187 | | add-new wrapper | 187 | 219 | Overlap 0, header-to-first-row gap 0. Only the ungrouped virtualized table was affected. `RecordTableRecordGroupRows` has the same `position: relative` wrapper, but its rows are in normal flow, so the header change just shifts the whole body down a pixel with no overlap possible. ## Testing Ran the app locally against seeded data: - Workflows (2 rows): border restored across the full width, geometry above verified in the DOM. - Companies (599 rows): header 33px, first row flush at 0, uniform 33px pitch across all 240 mounted row containers; scrolled and confirmed rows slide under the sticky header cleanly. - Verified the diagnosis independently by toggling the end drop zone to `position: static` in the running page, which restores the border the same way. `npx nx typecheck twenty-front` and `npx nx lint:diff-with-main twenty-front` are green. --- _Generated by [Claude Code](https://claude.ai/code/session_01VNEMsZbCA7x55n2trMhE47)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23846?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ae235d3b24 |
Scope sessions and application authorizations to the workspace (#23843)
Settings / Profile / Devices listed every live session for the account, so a person who belongs to two workspaces saw all of them from either one, and "Log out all other devices" signed them out everywhere. Almost nothing in Twenty is account-wide, and a session belongs to the workspace its exchange selected, so neither the list nor the revocations are another workspace's business. ## What was wrong `currentUserSessions` called `findActiveSessionsForUser(user.id)` with no workspace filter. Both revoke paths were keyed on `userId` alone, so `revokeUserSession` could target another workspace's session by id and `revokeAllOtherUserSessions` cleared every workspace at once. Sweeping the other resolvers that take `@AuthUser()` turned up the same shape in the OAuth application authorizations added in #23678: `findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({ authorizationId, userId })`. A grant made in one workspace was listed, and revocable, from another. Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`. `client-config.resolver.ts` is the reference pattern. ## The fix Both resolvers now take the workspace from the auth context and pass it down, and the service methods are renamed to say so. `revokeAllSessionsForUser` keeps `workspaceId` optional on purpose: `auth.service.ts` uses it on password change, where clearing every workspace is the intended behaviour. Sessions with no workspace (the workspace-agnostic ones minted on the default subdomain, which exists to list workspaces and carry the auto-login window) now belong to no workspace's list and survive "log out all other devices". ## Verification - New integration spec built on Tim's membership of both apple and yc: the list stays disjoint, a cross-workspace revoke by id is refused and is a no-op, and revoking all other devices in one workspace leaves the other signed in - Mutation-checked by dropping the `workspaceId` from the query, which fails the isolation test while the two revoke tests still pass, confirming each assertion targets its own mechanism - 160 unit tests, 70 integration tests across the session and OAuth suites ## Also The devices button drops its danger accent for the plain small variant, matching Deactivate in `ObjectSettings.tsx`. ## Separate finding, not fixed here `request.ip` resolves to an internal address behind the Cloudflare / nginx chain, which is why every row in the screenshot that prompted this showed the same RFC1918 address. That is an ingress configuration issue rather than an application one. It does not affect ClickHouse audit logs, which store no IP, but it does affect the two OAuth rate limiters that key on `req.ip`. --- _Generated by [Claude Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
26104d47a6 |
Report a sending domain as pending while SES waits for the DKIM records (#23818)
## Problem <img width="648" height="310" alt="image-1785940453654" src="https://github.com/user-attachments/assets/60362b8c-fed5-4ce3-af45-9aa064356f11" /> A sending domain that is simply waiting on its DKIM records is displayed as **Failed**, with every DKIM row marked **Error**, even when the DNS is correct and AWS has already published the key. Hit while setting up `twenty.dev` for a demo. All five CNAMEs resolve correctly from the authoritative nameserver and from a public resolver, none are proxied, the unsubscribe row is green, and the first DKIM token already resolves through to its published key at AWS: ``` $ dig +short TXT abbr…._domainkey.twenty.dev abbr….dkim.amazonses.com. "p=MIIBIjANBgkq…" ``` Yet all three DKIM rows read Error, which tells the user to go fix DNS that isn't broken. ## Cause `determineVerificationStatus` treats `VerifiedForSendingStatus === false` as terminal: ```ts if ( identityResponse.VerifiedForSendingStatus === false || dkimStatus === 'FAILED' ) { return EmailingDomainStatus.FAILED; } return EmailingDomainStatus.PENDING; ``` SES reports `VerifiedForSendingStatus: false` for the entire period it is waiting to detect the DKIM CNAMEs, which is the normal state of every domain between setup and verification. So a pending domain returns FAILED, and the PENDING branch is unreachable for any identity where the field is present at all. `TEMPORARY_FAILURE`, which SES documents as retryable, was also reported as Failed. The status is then stamped onto each DKIM row by `withRecordStatus`, which is why all three rows change together and none of them reflects its own record. |
||
|
|
299ebb1890 |
i18n - translations (#23845)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
7e00298044 |
Rename credits "Increase" button to "Manage" (#23840)
The green primary button in the Credits section was labelled "Increase"
with an up-arrow icon, but it opens the credit package picker, whose
slider spans every available package including smaller ones. So the
label promised upgrade-only while the modal supports both directions.
Renamed it to "Manage" and swapped the up arrow for `IconAdjustments`
(sliders), matching the slider-based picker it opens.
The secondary shortcut buttons ("Increase to $100", "Increase to $200")
are unchanged since those really do apply an upgrade directly.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CmtdBmWL9eSD3ZX7tgzisZ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23840?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
692c0c8402 |
i18n - translations (#23844)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
647a6aec58 |
Add nested relation Field widgets on record page layouts (#23815)
## Context Record pages can show a list of directly related records (Field widget in Table display mode), but not records two relation hops away. The canonical ask: on a Client page, list the Transactions of the Client's Wallets. Stacked on #23814 (merged) and #23832 (merged); their commits are included in this branch. #23836 stacks on this PR to add many-to-one first hops. ## How it works The 2-hop case does not need any new query capability. It reuses the relation traversal filter shipped for advanced filters: the widget embeds a view on the terminal object (Transaction) with one seeded filter `inverse relation IS current record`, traversed one hop (`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId` = Wallet.client, value `isCurrentRecordSelected`). At query time this compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which is within the backend's `MAX_RELATION_FILTER_DEPTH = 1` since the second hop lands on the join column. Records from all intermediate records (all wallets of the client) are listed, so one-to-many fan-out on the first hop works out of the box. ## Changes Configuration - `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId` (shared type, DTO, GraphQL fragment, regenerated metadata types). Backward compatible: existing widgets are untouched. UI - The Field picker drills into one-to-many relation fields, mirroring the advanced filter submenu pattern: back header, an entry to select the relation itself (previous behavior), then the target object's one-to-many relations. Selecting a nested field creates a widget titled `First hop → Second hop` in Table display mode. First-level rows that open a submenu never show the checkmark; the selected chain is only visible inside the submenu, matching the chart group by field selection. - The layout dropdown, settings panel and renderer resolve the terminal object of the chain; a widget whose second hop was deleted or deactivated renders nothing instead of silently showing first-hop records. - Nested widgets only offer embedded view layouts (Table / Kanban / Calendar), since inline display modes would render the first hop's relation field. - The relation table view resolver regenerates the embedded view whenever the selection results in a table widget and the chain changed or the view id is missing, so a table widget can never carry a view belonging to a different chain. Server - `FieldConfigurationDTO` accepts the new optional field. - Both universal configuration mappers (to and from universal identifiers) carry it for app manifest sync. - New `validateFieldConfigurationNestedRelationOrThrow` enforces that both hops are active one-to-many relation fields on the right objects, wired next to the existing chart field reference validation. Record creation - `buildRecordInputFromFilter` skips relation-traversal filters: they constrain a related record's column, so prefilling the created record's own foreign key from them would link the wrong record (e.g. `walletId = clientId`). - Add New in a nested widget table instead prompts for the record to create through: the row opens a picker listing the current record's first-hop records (the client's wallets), scoped with a find filter on the relation join column, and creates the record with the picked id prefilled. Covers the plain table and per-group add rows. Board and calendar layouts hide their create buttons in nested widgets since they cannot know the record to create through. - Matching the created record against the widget's traversal filter client side is handled by #23832. Out of scope, deliberately: depth stays at exactly two levels (matches the backend filter depth cap), junction and morph relations are not drillable, and chart widgets on record pages are untouched. ## Tests - Unit: nested chain resolution util, draft view seeding with the traversal filter, view id change resolver, picker parameter derivation, server-side validation. Full `page-layout` and `record-filter` front suites pass (187 suites / 1233 tests), server `page-layout-widget` suites pass. - Manual, on seeded data: created a `People → Opportunities` widget on a Company page; it lists exactly the opportunities whose point of contact belongs to that company, persists across save and reload, and scopes per record. Add New opens a picker showing only that company's people; picking one creates an opportunity with `pointOfContactId` set (verified in DB) and the row appears in the widget immediately. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |