c596a5e342d28576e1ece54ad86eadcfd7cbd3b5
1163 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c596a5e342 |
Rename twenty-ui to twenty-ui-deprecated and twenty-new-ui to twenty-ui to prepare package release (#21315)
## Description Promotes the next-gen UI library (formerly `twenty-new-ui`) to the name **`twenty-ui`** (v0.1.0, publishable) and renames the old package to **`twenty-ui-deprecated`**. Rewrites ~1,730 `twenty-ui` imports → `twenty-ui-deprecated`, updates all configs/CI/Docker/deps, and migrates twenty-front's `Toggle` to the new package (first consumer) as a drop-in. ## Next steps - Wire the `ui/v*` publish dispatch (`cd-deploy-tag.yaml` + `.yarnrc.yml`), then tag `ui/v0.1.0` to publish. - Continue migrating components from `twenty-ui-deprecated` → `twenty-ui`. |
||
|
|
dfb3da1f8d |
feat(emailing-domain): add LOG driver for local development (#21286)
## What Adds a `LOG` driver to the emailing-domain feature, selected via a new `EMAILING_DOMAIN_DRIVER` config variable (defaults to `AWS_SES`, so production behavior is unchanged). The LOG driver: - resolves domains to `VERIFIED` instantly (no DNS / SES setup) - logs each `sendEmail` and returns a synthetic `messageId` instead of calling SES It also dev-seeds a pre-verified domain per workspace (`<workspaceId>.dev.twenty.local`) so the feature works out of the box. ## Why The emailing-domain feature currently ships only the AWS SES driver, so the verify → send flow can't be exercised locally (or in CI) without real AWS credentials. This unblocks local development and review of anything built on emailing domains. ## Usage ``` EMAILING_DOMAIN_DRIVER=LOG ``` The seeded `*.dev.twenty.local` domain is already verified; sends are logged (`[log-driver] sendEmail ...`). --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
4658d44d8b |
fix(settings): ship borderless hero cover images (#21277)
## What The settings discovery hero images (AI, Applications, Page Layouts, Members, Data Model, APIs & Webhooks) baked the rounded border into the pixels — transparent rounded corners plus a 1px edge stroke. Rendered inside `Card rounded` — which already draws a 1px border + border-radius and clips children with `overflow: hidden` — this produced a doubled, slightly misaligned border. This replaces all 12 files (light + dark per section) with clean full-bleed exports (opaque square corners), so the border and rounding come entirely from CSS. ## Notes - Pure asset swap, no component changes. - The MCP section's `.svg` cover is untouched (no new export provided). Billing's unused cover is left as-is. ## Verification - Each new image confirmed 1388×300, opaque square corners (no baked border), correct light/dark variant. - `Card` (twenty-ui) provides `border` + `border-radius` + `overflow: hidden`, so the square images are clipped to the rounded card. |
||
|
|
91f2f08995 |
feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`, `usageEvent`, `applicationLog`) each wrote to ClickHouse through their own fire-and-forget writer (`AuditService`, `UsageEventWriterService`, and the `application-logs` driver), with the per-type knowledge (table names, normalization, access rules) spread across several modules. Three of them reimplemented the same ClickHouse insert, and the read side, the live stream, and the producers lived in different modules under two different names. This consolidates them into one `core-modules/event-logs/` subsystem (emit, write, live, read), with the per-type config in a single registry so adding an event type is roughly one file. The base Logs settings tab and free application logs shipped separately in #21180 (merged). This PR adds the unified backend, the registry, and the viewer's live mode and entitlement gating. ## Pipeline ```mermaid flowchart TB subgraph PROD["Producers"] A["auth, billing, impersonation,<br/>webhook, custom-domain"] U["usage listener"] F["logic-function executor (app logs)"] R["record CRUD (entity events)"] end EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"] EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"]) CIE["CreateEventLogFromInternalEvent"] SINK["WorkspaceEventSinkService.ingest()"] C1["ClickHouseEventSink"] C2["ConsoleEventSink"] LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"] CH[("ClickHouse, 5 tables, async_insert")] CHAN(["WORKSPACE_EVENTS_CHANNEL"]) RS["EventLogsService (registry-driven read)"] LR["EventLogsLiveResolver"] UI["Settings > Logs"] A --> EM U --> EM F --> EM EM -->|direct| SINK R --> EQ --> CIE -->|ingest| SINK SINK --> C1 --> CH SINK --> C2 SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI CH --> RS --> UI ``` ## What it does - Producers call `EventLogEmitterService.createContext().insert*()`, which builds a typed `WorkspaceEventEnvelope` and writes it through `WorkspaceEventSinkService` to the configured sinks (ClickHouse, Console) plus a presence-gated live fan-out. Record/CRUD events reach the same sink through the existing `entityEventsToDbQueue`. There is no dedicated queue; ClickHouse `async_insert` batches server-side. Writes are best-effort, as on main today. - `EVENT_LOG_TYPES[table]` is the per-type source of truth: the ClickHouse table, the required entitlement, the free-text filter column, and the row-to-GraphQL mapping. Read row shapes derive from the write rows. - Four modules along their dependency boundaries: `EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the entitlement-gated GraphQL read, which is where billing/enterprise/permissions stay so producers stay light). - Logs viewer: per-table columns, filters (text, date, record), live mode, and an upgrade card that points to Billing on Cloud or the Admin Panel on self-hosted. Application logs are free on every plan; the other four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT` fallback to the upgrade card). - Renames `AuditService` to `EventLogEmitterService`, and the generic `Monitoring` event to a typed `Impersonation` event (`level` + `action`). - Removes `UsageEventWriterService`, the `application-logs` driver/module, and `AuditService`'s direct inserts. ## Durability Writes are best-effort, the same as main today (the old writers were fire-and-forget). A dedicated queue was tried mid-PR and removed: `async_insert` already batches server-side, so the queue only added durability, which isn't a requirement right now. The `EventSink` seam keeps a durable transport (e.g. a Redis-Streams buffer) easy to add later without touching producers. ## Out of scope S3 peer sink (seam only), Postgres or any second read path, `ReplicatedMergeTree`, ClickHouse table-schema changes, and the record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern). ## Testing Unit tests cover the registry definitions and row normalization, the entitlement gating, the envelope builders, and the producers. Integration tests cover the write paths (record create produces an `objectEvent`; the track mutation produces a `workspaceEvent`) and the read/query path across all five tables. Verified with typecheck, lint, a server boot, and GraphQL/SDK codegen. |
||
|
|
1b30983307 |
fix(settings): gate the AI settings page on AI_SETTINGS, not the chat flag (#21239)
## Summary Closes #21229. The two AI role permissions behaved **opposite to their labels**. The trap is that the flag's code name is the inverse of its UI label: | `PermissionFlagType` | UI label | Section | Means | |---|---|---|---| | `AI` | **"Ask AI"** | Actions | End-user: chat with AI | | `AI_SETTINGS` | **"AI"** | Member / settings | Admin: configure AI agents | Before this PR (on `main`): - `AI` ("Ask AI", chat) gated **both** the AI chat **and** the AI settings page. - `AI_SETTINGS` ("AI", configure agents) gated **nothing** the user could see. So a chat-only user could reach the whole AI **configuration** page, and toggling the "AI" settings permission did nothing — exactly the misalignment reported in #21229. ## Root cause `PermissionFlagType.AI` *reads* like "the AI permission", so it looks like the natural gate for the AI settings page — but it's actually the **chat** flag. The settings page (nav item + route) had been pointed at `AI` in #21072 to match the Overview stats query (`findWorkspaceAiStats`), which was itself mis-gated on `AI`. Both the stats query and the rest of the settings surface are admin/config features, so they belong on `AI_SETTINGS`. ## Changes All three move the **AI settings surface** from the chat flag (`AI`) to the settings flag (`AI_SETTINGS`); chat keeps following `AI`: - `useSettingsNavigationItems.tsx` — AI nav item → `AI_SETTINGS` - `SettingsRoutes.tsx` — AI settings route group → `AI_SETTINGS` - `ai-workspace-stats.resolver.ts` — `findWorkspaceAiStats` (settings-only, drives the Overview tab) → `AI_SETTINGS` After this: the "AI" permission controls the AI settings page + its Overview; the "Ask AI" permission controls the chat. Both toggles now match their labels. ## Test plan - [ ] Role with **only "Ask AI"** (`AI`): AI chat tabs/pane visible; **Settings → AI is hidden** and the route is not reachable. - [ ] Role with **only "AI"** (`AI_SETTINGS`): Settings → AI is visible, Overview stats load; chat nav is hidden. - [ ] Admin (both flags): everything works as before. ## Known follow-ups (out of scope — pre-existing, shared endpoints) These remain on `AI` because they're shared with non-settings surfaces and need either OR-gating or a resolver split, so a role with `AI_SETTINGS` but **not** `AI` still can't use them yet: - `getAiSystemPromptPreview` (Models/Prompts tabs) lives in the chat resolver, class-gated `AI`; NestJS guards are additive so it can't be cleanly method-overridden — it should be pulled into a settings resolver. - Agent reads `findManyAgents` / `findOneAgent` (agent create/edit forms) are class-gated `AI` and shared with the **Workflow** editor and **Roles** pages; these want a guard that accepts `AI ∨ AI_SETTINGS ∨ WORKFLOWS`. |
||
|
|
a3a44c8315 |
fix(front): settings skeleton, app-detail header & empty favorites (#21209)
Three small post-redesign UI fixes. Each is an independent commit, so they can be split into separate PRs if preferred. ## 1. Settings loading skeleton — match the rounded-card layout The redesign (#21131) moved settings chrome into a rounded card (`SettingsPageLayout`: bordered header with breadcrumb + centered title, optional secondary bar, 760px body), but `SettingsSkeletonLoader` still rendered the old flat `PageHeader` + `PageBody` — so pages painted as a full-width flat bar then snapped into the card. - `SettingsSkeletonLoader` now reproduces the card and **reuses the real `SettingsPageHeader` + `SettingsPageContainer`**, so the frame aligns by construction; the card CSS is replicated (not `SettingsPageLayout`) to avoid the layout's side effects (hotkeys, side panel, info banner). - It's **composed with `SettingsSectionSkeletonLoader`** so the loading body is identical whether or not chrome is present. Rule: no chrome on screen yet → full-page skeleton; chrome already on screen → body-only `SettingsSectionSkeletonLoader` (the admin Enterprise tab now uses it, matching its sibling tabs). A short comment on each component documents this. ## 2. Application detail header — pass a plain title `SettingsApplicationDetails` / `SettingsAvailableApplicationDetails` passed a custom `SettingsApplicationDetailTitle` (avatar + name + multi-line description, fixed width) into `SettingsPageLayout`'s **centered single-line title slot**, which broke the header. They now pass the app's display name like every other page. The available-app "unlisted" notice moves into the body as a reusable `InlineBanner`; the now-unused `SettingsApplicationDetailTitle` is removed. ## 3. Navigation — hide Favorites when empty Always rendering the Favorites section (#21087) left a stray "Favorites" title above Workspace for users with no favorites. It now renders only when at least one favorite exists (redundant per-child guards dropped). Note: the "+ add favorite" entry point therefore appears once you have ≥1 favorite; the first favorite is created from a record/view as before. ## Verification - `nx typecheck twenty-front` ✅ · `oxlint` + `oxfmt --check` on changed files ✅ - i18n catalogs intentionally untouched — handled by the repo's separate i18n pipeline. |
||
|
|
2ac515894b |
feat(settings): add Logs as a dedicated tab in General settings (#21180)
## What & why The audit-log viewer lived as a full-screen page reachable only via a "View Logs" button buried in the **Security** tab. This surfaces it as the **third tab in General settings** (`General | Security | Logs`), consistent with the other tabs. ## Changes - **Relocated** the event-logs module `pages/settings/security/event-logs/` → `modules/settings/event-logs/` and render it as tab content instead of a `FullScreenContainer` page. Dropped `SettingsPath.EventLogs`, its route, and the fullscreen handling in favor of the `general#logs` hash tab. - **Security tab:** removed the "View Logs" entry; kept the log-retention setting there. - **In-tab gating** (shown to users with the Security permission): Enterprise upgrade card when not entitled, a clear "ClickHouse not configured" placeholder otherwise (derived from client config), and the query is skipped when disabled. Replaces a bespoke error component that string-matched error messages with the shared `SettingsEmptyPlaceholder` / `SettingsEnterpriseFeatureGateCard`. - **Layout:** boxed content column with the table selector + filters grouped in a `Card` and the results table below, matching settings conventions. Kept the existing fixed filters (page/event name, member, period) rather than recreating the record-view filter chips (those are tightly coupled to record/view context). Frontend + `twenty-shared` only — no changes to the log query or data. ## Test plan - [x] `npx nx typecheck twenty-front` and `npx nx lint twenty-front` pass - [x] Settings → General shows three tabs; Logs is the third; breadcrumb stays "Workspace / General" - [x] With Enterprise + ClickHouse: table selector, filters, refresh, and the paginated table work - [x] Non-Enterprise: Enterprise upgrade card shown; no failing query fires - [ ] Enterprise without ClickHouse: shows the "ClickHouse not configured" placeholder - [ ] Security tab still shows the log-retention setting and the "View Logs" button is gone - [ ] A user without the Security permission sees neither the Security nor Logs tab |
||
|
|
ccffc4a1ea |
Fix axios related dependabot alerts generated against root yarn.lock (#21187)
Fixes the following Dependabot alerts: https://github.com/twentyhq/twenty/security/dependabot?q=is%3Aopen+package%3Aaxios+manifest%3Ayarn.lock+has%3Apatch Upgraded the referenced version in root yarn.lock. Creating a separate PR for the nested ones to keep the updates isolated (e.g. /seed-dependencies/yarn.lock). |
||
|
|
d3a7ea0790 |
Fix: Not able to add multiple handles to Blocklist in settings/accounts (#21049)
Fixes: #21031 ### Root cause: This feature was never fully implemented even though the placeholder text suggested it was supported. It could only update one handle at a time. ### Fix Fixed the zod validation to validate each handle separately. Used `useCreateManyRecords` to update multiple handles at the same time. ### Before: <img width="2032" height="1162" alt="Screenshot 2026-05-29 at 3 25 12 PM" src="https://github.com/user-attachments/assets/ae6b6ae3-ed38-4410-801e-11f514773681" /> ### After: https://github.com/user-attachments/assets/69354129-422a-41de-baf7-fa5a28f01f3f --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
120793f69f |
fix: block self-impersonation in admin panel (#21130)
## Issue - From Settings -> Admin Panel -> Workspace -> Members, impersonating the currently logged-in user still issued an impersonation login token. Token exchange produced invalid impersonation JWTs (`impersonatorUserWorkspaceId === impersonatedUserWorkspaceId`). JWT validation then failed with `User cannot impersonate themselves`, leaving the app in an endless loading state until cookies were cleared. - Closes #21086 ## Approach I was first thinking of to only hide the impersonate button for the logged-in user in the admin, since they can not click what isn’t shown (as I thought it was just a frontend issue). But that was not enough: - The `impersonate` mutation can still be called directly (GraphQL client, scripts, devtools). - Before this fix, the mutation could succeed and only fail later at JWT validation, which led to invalid tokens and a broken session. So the PR does both: - Frontend: hide/disable self-impersonation in the UI and avoid reloading on failed token exchange (UX). - Backend: reject self-impersonation in `ImpersonationService` and at token exchange (enforcement, fail fast before bad tokens). Hiding the button is the right product behavior; the backend change is what makes the rule real and safe. ## How to test Manual: - Log in as a user with admin impersonation. - Go to Settings -> Admin panel -> Workspace -> open your workspace -> members. - Confirm your row has no Impersonate button; other members still do. - Open Admin Panel -> User for yourself -> confirm no impersonate button. - Open Settings -> Members -> your own member profile -> confirm no Impersonate action. - Impersonate another member -> should work as before Automated: `npx jest impersonation.service.spec` ### Before: <img width="830" height="413" alt="Screenshot 2026-06-02 122224" src="https://github.com/user-attachments/assets/46f38a74-8bd6-4ffa-b749-500ce18314f1" /> ### After: <img width="795" height="369" alt="Screenshot 2026-06-02 122333" src="https://github.com/user-attachments/assets/62ece4a8-d38b-4f91-817f-792ff49b146b" /> --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
431f6ae98f |
feat(settings): move settings chrome into a single rounded card (#21131)
## What Replaces `SubMenuTopBarContainer` with a settings-specific `SettingsPageLayout` that puts the whole page chrome — breadcrumb, centered title, actions, an optional secondary bar (tabs or wizard step), and the 760px body — inside **one rounded card**, with `SidePanelForDesktop` as a sibling. Title, tabs and body content share one centered vertical axis at every card width. Supersedes #21122. One PR, no feature flag. ## New components (`@/settings/components/layout/`) - **SettingsPageLayout** — owns the rounded card + side-panel sibling, `useCommandMenuHotKeys`, mobile command menu - **SettingsPageHeader** — breadcrumb · centered title · actions in a symmetric `1fr auto 1fr` grid (symmetric padding throughout) - **SettingsSecondaryBar** — the secondary row, bracketed by top + bottom borders - **SettingsTabBar** — centered tabs reusing `activeTabIdComponentState` + `TabListFromUrlOptionalEffect` for URL-hash sync (does not touch the shared `TabList`) - **SettingsWizardStepBar** — back arrow · "N. Label" · optional trailing slot ## Migrations - Bulk rename across ~80 call sites (`SubMenuTopBarContainer` → `SettingsPageLayout`); old component deleted. - 5 tab pages (AI, APIs & Webhooks, Applications, Members, Role) + the Data Model object-detail page render their tabs in `secondaryBar` (object-detail keeps "See records" / "New Field" in the header actions). - The 2 role object-level steps render the wizard step bar with working back navigation. - Accounts consolidated into **General / Emails / Calendars** tabs; standalone `SettingsAccountsEmails` / `SettingsAccountsCalendars` pages + routes + stories removed. `SettingsPath.AccountsEmails` / `AccountsCalendars` now resolve to `accounts#emails` / `accounts#calendars`, so existing `getSettingsPath()` links deep-link to the right tab via the existing hash sync — no call-site changes. ## Verification - `nx typecheck twenty-front` and `nx lint twenty-front` both clean. - Browser (logged-in workspace): title / tab / body / card centers align on a single axis at multiple widths — width-invariant, so alignment holds when the AI side panel (a sibling) shrinks the card. Rounded card with even gaps on all four sides; tab row bracketed by two 1px lines; no-tab pages render header → body with no lines; wizard back navigation works; `…/accounts#emails` opens the Emails tab. The shared `PageHeader` and `TabList` are untouched. The settings side panel itself isn't wired to open yet — that's a follow-up PR. |
||
|
|
75df1f3997 |
chore(settings): address review comments from PR 21072 (#21121)
## Summary Round through bosiraphael's 31 review threads on the merged PR #21072 (discovery hero + ephemeral playground token). The user asked to apply each suggestion only where it adds value, so this PR is split into three buckets. ### Comments (~17 threads) - Tightened security-rationale / CSS-gotcha / API-doc comments to one or two factual lines - Kept (shortened) the comments above `RequireAccessTokenGuard` call sites — without them a future reader could remove the guard and silently reopen the escalation hole - Kept (shortened) the in-memory-only rationale on `playgroundApiKeyState` for the same reason - Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer` — non-obvious and easy to break ### Structure / extraction - Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants file (one-export-per-file) - Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across queries/, hooks/, types/, utils/: - `graphql/queries/findManyApplicationsForToolTable.ts` - `graphql/queries/findManyMarketplaceAppsForToolTable.ts` - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging) - `types/SettingsAgentToolItem|Application|MarketplaceApp` - `utils/getToolApplicationId|getToolLink` - Extract `SettingsAiModelsTab` optimistic mutations into `hooks/useSettingsAiModelsActions` (handleModelFieldChange, handleUseRecommendedToggle, handleModelToggle, handleToggleAllVisibleModels) - Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool` - Drop unnecessary `useMemo` wrappers on `heroTabs` arrays (SettingsObjects, SettingsLayout) - Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab: `onToggleChange={setShowDeactivated}` (no longer wrapping with arrow + read of stale `!showDeactivated`) ### Hero assets - Replace placeholder `customize-illustration` with per-page exports - Rename `layout/customize-illustration-{light,dark}.png` → `layout/cover-{light,dark}.png` - Add `cover-{light,dark}.png` for **applications** and **members** (they were both pointing at the layout placeholder as a TODO) - Overwrite `data-model/cover-*.png`, `playground/cover-*.png`, `ai/ai-tools-cover-*.png` with the new exports ## Test plan - [ ] `npx nx typecheck twenty-front` ✅ - [ ] `npx nx typecheck twenty-server` ✅ - [ ] `npx nx lint twenty-front` ✅ (oxlint + oxfmt, 0 warnings/errors) - [ ] `/settings/layout`, `/settings/data-model`, `/settings/applications`, `/settings/ai`, `/settings/api-webhooks`, `/settings/members` each render the new hero illustration (light + dark) - [ ] AI tab: tool list still loads, search + Custom/Managed/Standard filters still work, "New Tool" still navigates to detail - [ ] AI tab: Models tab — smart/fast model select, "Use best models only" toggle, per-model checkboxes, toggle-all all still optimistic+revert on error - [ ] Skills tab: "Deactivated" toggle still flips show/hide - [ ] Webhooks table still uses the 1fr 28px grid |
||
|
|
b338a7a1d2 |
feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
|
||
|
|
667cb95730 |
fix(sso): accept HTTP-POST binding and surface descriptive parser errors (#21051)
Fixes https://github.com/twentyhq/twenty/issues/21044 ## Summary - Closes [#490](https://github.com/twentyhq/private-issues/issues/490) — JumpCloud customers (and anyone else whose IdP only advertises `HTTP-POST` for `SingleSignOnService`) could not upload their SAML metadata; the parser silently rejected them with a generic `Invalid file` toast. - The SAML IdP metadata parser now falls back to `HTTP-POST` when `HTTP-Redirect` is not advertised. Both are valid SAML 2.0 bindings. - The parser now returns a descriptive `reason` string (Zod issues + custom errors) instead of an opaque `error: unknown`, and the upload snack bar surfaces it so the customer can self-diagnose (e.g. `entityID: entityID is not a valid URL` if they forgot to fill in their IdP Entity ID). - Added unit tests for HTTP-POST-only metadata, HTTP-Redirect preference, and each descriptive-error path. ## Test plan - [x] `npx jest parseSAMLMetadataFromXMLFile --config=packages/twenty-front/jest.config.mjs` — 8/8 pass - [x] `npx oxlint -c packages/twenty-front/.oxlintrc.json` on changed files — clean - [x] `npx oxfmt --check` on changed files — clean - [ ] Manual: upload the customer's JumpCloud metadata (HTTP-POST only, placeholder `entityID`) and confirm the error now says `Invalid file: entityID: entityID is not a valid URL` instead of `Invalid file` - [ ] Manual: upload metadata with a real `entityID` and HTTP-POST-only binding, confirm the form populates correctly |
||
|
|
c2df39405c |
Fix admin pannel server variable config tab (#21017)
## Before <img width="1046" height="490" alt="image" src="https://github.com/user-attachments/assets/450557de-fcf5-4b51-afdb-36c0c36e43d8" /> ## After <img width="1040" height="414" alt="image" src="https://github.com/user-attachments/assets/4a5fe2ab-85d6-4431-9397-6f81ae24055d" /> |
||
|
|
3c97d9648b |
fix(address): show saved address in record detail when street1 is null (#21033)
## Fixes #20084 ### Problem A saved address is visible in the **table view** but shows **"Empty"** in the **record detail page** when `addressStreet1` is `null`. This reproduces with the default seed data out of the box — e.g. **Google** (city "Mountain View", no street), **Microsoft** (Redmond), **Meta** (Menlo Park) — which is why several users reported hitting it immediately. ### Root cause The frontend zod schema required `addressStreet1` to be a **non-null** string: ```ts // isFieldAddressValue.ts export const addressSchema = z.object({ addressStreet1: z.string(), // ← required non-null addressStreet2: z.string().nullable(), ... }); ``` …but the backend composite type marks it `isRequired: false` (`address.composite-type.ts`), and the DB column is nullable. So the API legitimately returns `addressStreet1: null` when only other subfields are filled. The two views diverge on how they render: - **Record detail** gates the value behind `useIsFieldEmpty()` → `isFieldValueEmpty()`, which for addresses calls `isFieldAddressValue()`. With `addressStreet1: null` the `safeParse` **fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"` placeholder is shown (`RecordInlineCellDisplayMode`). - **Table view** (`RecordTableCellDisplayMode`) renders `AddressFieldDisplay` directly with **no** empty check, so the address stays visible. This was a latent mismatch since the address guard was introduced. ### Fix Make `addressStreet1` nullable to match the backend and the other subfields: - `addressSchema` → `addressStreet1: z.string().nullable()` - `FieldAddressValue.addressStreet1` → `string | null` - `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the input/draft type consistent; the text input already renders `?? ''`) The change is strictly more permissive — persisting and the settings default-value form still accept string values; they now also accept `null`. ### Tests - `isFieldAddressValue.test.ts` — guard returns `true` for `addressStreet1: null` with other subfields filled. - `isFieldValueEmpty.test.ts` — new address coverage: empty address is empty; **`street1: null` + city filled is NOT empty**; normal address is not empty. (Added an `addressFieldDefinition` mock.) Both new assertions were confirmed to **fail before the fix** and pass after. ### Verification - `npx jest isFieldValueEmpty isFieldAddressValue normalize-address-field-value-for-persist` → 17 passed - `npx nx typecheck twenty-front` → pass - `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors |
||
|
|
996cdaf3ff |
refactor(agents): split tool resolution into native and action rails (#20331)
## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
7d9f9605a2 |
feat(settings): move email handles and emailing domains to dedicated Email page (#21008)
## Summary Both **Email Handles** and **Emailing Domains** were rendered on the General workspace settings page, but they're workspace-level *email infrastructure* (inbound shared addresses + outbound sender authentication) and don't belong with the workspace name, picture, and domain config. - New `SettingsWorkspaceEmail` page at `/settings/email` - Nav item under **Workspace**, hidden when `IS_EMAIL_GROUP_ENABLED` is off (and gated by `WORKSPACE` permission) - Related sub-routes (`email-group/:messageChannelId`, `emailing-domain/:domainId`, etc.) moved from `general/` to `email/` so the URL space stays consistent with the page - General page now only contains name, picture, workspace domain, and the delete-workspace section No behavior changes to the underlying section components — they're imported as-is into the new page. ## Test plan - [ ] With `IS_EMAIL_GROUP_ENABLED` enabled: **Email** appears in the Workspace nav and the page renders both sections - [ ] With the flag disabled: **Email** is hidden from nav; navigating to `/settings/email` directly renders nothing - [ ] General page no longer shows Email Handles / Emailing Domains - [ ] Clicking a shared inbox row navigates to `/settings/email/email-group/:id` (was `general/...`) - [ ] "Add emailing domain" navigates to `/settings/email/emailing-domain/new` ## Notes - Pre-existing `twenty-front` typecheck error in `FrontComponentRendererProvider.tsx` (React types mismatch between sibling packages) reproduces on `main` and is unrelated to this PR. |
||
|
|
7065441972 |
fix: refresh admin panel feature flags after toggling (#21007)
## Summary
- Add `refetchQueries` to the `updateWorkspaceFeatureFlag` mutation in
the admin workspace detail page so the toggle reflects the new value
after toggling.
- Rename `useFeatureFlagState` → `useAdminUpdateFeatureFlag` since the
hook lives under `admin-panel` and is only consumed by the admin
workspace detail page.
## Bug
In the admin panel, toggling a feature flag for a workspace other than
the admin's own workspace sent the backend mutation successfully, but
the toggle in the UI remained unchanged.
The displayed value is derived from:
```tsx
const currentWorkspaceValue =
currentWorkspace?.id === workspaceId
? currentWorkspace?.featureFlags?.find((f) => f.key === flag.key)?.value
: undefined;
const displayedValue = currentWorkspaceValue ?? flag.value;
```
When viewing a different workspace, `currentWorkspaceValue` is
`undefined` so the toggle reads `flag.value` from the
`WORKSPACE_LOOKUP_ADMIN_PANEL` query. That query was never refetched
after the mutation, so the displayed value stayed stale.
The existing optimistic Jotai update on `currentWorkspaceState` still
runs — it is needed so the rest of the app (anything consuming
`useIsFeatureEnabled`) reacts immediately when an admin toggles a flag
on their own workspace.
## Test plan
- [ ] Open the admin panel → pick a workspace that is not your own →
Feature Flags tab → toggle a flag → toggle visually flips after the
mutation completes.
- [ ] Same flow on your own workspace → toggle flips, and any UI gated
on that flag also reacts.
- [ ] If the mutation fails, the toggle reverts (existing `onError`
rollback path).
|
||
|
|
c5606212f2 |
Ses outbound followup (#20610)
This pull request unifies outbound with inbound under the new feature and the new email groups feature. These are workspace level shared inboxes that are shared between all workspace members. outbound sending with SES works, we only listen for tenant status events, rest is managed by AWS PR refactors old code and webhook to be split for outbound and inbound for proper separation | Area | Change | |---|---| | AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant + identity + DKIM + MAIL FROM + configuration-set + EventBridge dest + contact list) and `AwsSesSendEmailService` (SendEmail). | | Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES → EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS → router → `SesOutboundSendingStateHandlerService` updates `emailing_domain.tenantStatus`. | | Inbound webhook | Refactored into `SesInboundWebhookRouterService` + `SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` + `SnsSubscriptionConfirmerService` across both routes. | | Global uniqueness | New migration + instance command: `emailing_domain.domain` is now globally unique (one tenant per domain across workspaces). | | Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` / `PAUSED`) + `EmailingDomainTenantStatusService`. | | Send-email mutation | New `sendEmailViaDomain` GraphQL mutation + DTOs. | | Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into `WorkspaceService.deleteWorkspace` — tears down SES tenant association + identity on workspace delete. | | Settings UI | Rewritten around reusable `SettingsTableListSection`. "Email Group" → "Email Handle" rename. New cells for status/source/forwarding. Outbound domains surfaced on workspace settings page. | ### Env vars (new) All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional: - `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider` + driver factory - `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory - `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound webhook routers, comma-separated list of accepted SNS topic ARNs (verified via `sns-payload-validator`) ### Migrations - `1778862608620-add-emailing-domain-tenant-status` (fast) — adds `tenantStatus` column. - `1778865501791-unique-emailing-domain-globally` (slow, idempotent) — enforces global uniqueness on `domain`. - Instance commands bumped to `2.5`. ### Infra dependency Two coupled twenty-infra PRs: - `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket policy + KMS grant + `email_group_*` outputs. - `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM policy + outbound `webhook_url` subscription. **Based on `ses-inbound-email`.** Merge order: inbound first, then outbound. Outbound PR's chart edit owns the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs). Features lives under `/settings/general` <img width="1496" height="845" alt="SCR-20260519-ofhi-2" src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d" /> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com> |
||
|
|
951ead5a1e |
fix(front): ignore IME composition Enter in input hotkeys (#20958)
## What Pressing Enter to confirm an IME (CJK) composition no longer submits the input. The Enter / Escape / Tab handlers now ignore key events fired while a composition is in progress (`isComposing`, or the legacy `keyCode === 229`). Fixes #20954 ## Why `isComposing` was not checked anywhere in `twenty-front`, so the Enter that confirms a Japanese / Chinese / Korean conversion was also consumed as a submit / escape / tab hotkey — making it very hard to type CJK text into any input that submits on Enter. ## Changes - `useHotkeysOnFocusedElement` — central guard; covers every input wired through `useRegisterInputEvents` (~13 components) and all hotkeys routed through this hook. - Direct `onKeyDown` Enter handlers: `CreateWorkspace`, `SettingsDevelopersApiKeysNew`, `SettingsAccountsBlocklistInput`. ## Notes - No effect on non-IME (Latin) typing — `isComposing` is only true during an active composition. It also improves accented / dead-key input on Latin layouts. - `react-hotkeys-hook@4` does not handle IME composition on its own, so the guard is explicit. ## Testing Manually verified with a Japanese IME on Chrome (macOS) against the v2.8.3 self-hosted image: romaji + Enter now only confirms the conversion; a second Enter on committed text submits as expected. The GIF in #20954 shows the original buggy behavior. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
986b9dcb3d |
Deprecate dummy enterprise key 1/2 (#20890)
Remove usage of hasValidEnterpriseKey in FE (replaced by hasValidSignedEnterpriseKey) To avoid breaking change at deploy time, we will wait until after this has been deployed in prod, to remove hasValidEnterpriseKey in the BE. |
||
|
|
8cb88cabee |
fix(role): rebind API keys + agents before deleting their role (#20935)
## Customer-reported bug
A customer hit this when using the AI chat:
```json
{
"message": "API key 760d4822-da40-4b3f-9031-40563d7ed6c9 has no role assigned",
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"userFriendlyMessage": "This API key has no role assigned."
}
}
```
Their integration authenticates via API key. Somewhere along the way,
the role bound to that API key was deleted, leaving the API key
authenticated but role-less. Any request that hits a permission check
(`getRoleIdForApiKeyId`) blows up.
## Root cause
In `RoleService.deleteManyRoles`, the pre-deletion cleanup
(`assignDefaultRoleToMembersWithRoleToDelete`) only rebinds **user
workspaces** to the workspace default role. API keys and agents pointing
at the role are ignored. Because `RoleTargetEntity.role` declares
`onDelete: 'CASCADE'`, the FK then drops the role_target rows for those
API keys / agents — but the API keys themselves stay in `api_key`, now
orphaned in `apiKeyRoleMap`.
A previous read-side workaround
([2767ddac44](https://github.com/twentyhq/twenty/commit/2767ddac44) —
make the `role` ResolveField nullable) handled the API-key-details page,
but did not address the write paths (`getRoleIdForApiKeyId`).
## Fix
- Rename `assignDefaultRoleToMembersWithRoleToDelete` →
`rebindTargetsOfRoleToDeleteToDefaultRole` and extend it to rebind API
keys (via `ApiKeyRoleService.assignRoleToApiKey`) and agents (via
`AiAgentRoleService.assignRoleToAgent`) in the same step, before the
role is deleted.
- If the workspace default role doesn't satisfy `canBeAssignedToApiKeys`
/ `canBeAssignedToAgents`, the inner `assignRoleTo*` validation throws.
We catch that and rethrow as a `PermissionsException` with a
role-deletion-context message and two new codes —
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` /
`ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS` — so the admin sees a clear
"reassign these first" prompt rather than a confusing inner error.
## Scope / non-goals
- **Already-orphaned API keys are not auto-healed.** The customer still
needs to reassign a role to their existing orphan API key via the UI
(Settings > API Keys > [the key] > role). A separate cleanup command for
existing orphans is a follow-up.
- I did not investigate *why* the customer's session was authenticated
via API key in the AI chat — that may be their integration setup. Worth
confirming with them separately.
## Test plan
- [ ] Workspace with default role `Admin` (which has
`canBeAssignedToApiKeys: true`): create an API key with a custom role,
delete the custom role → API key is rebound to Admin, requests keep
working.
- [ ] Workspace with default role `Member` (default, has
`canBeAssignedToApiKeys: false`): create an API key with a custom role,
delete the custom role → role deletion fails with the new
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` error explaining the admin must
reassign first. API key + custom role are both unchanged.
- [ ] Same two scenarios for agents (`canBeAssignedToAgents`).
- [ ] Existing user-workspace rebind behavior is unchanged.
- [ ] Role deletion with no dependent API keys / agents still works.
|
||
|
|
a051490ec9 |
Basic app logo fixes (#20919)
as title, took the quick win fixes from https://github.com/twentyhq/twenty/pull/20909/changes#diff-3367344412b2f44f0273d8019c1bc36396198244b9558d02921b135f62522baaR180 and leave the main fix for later as it requires an architectural update |
||
|
|
d602f35cbd |
feat(data-model): custom-indexes management UI and mutations (#20846)
## Summary
Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).
A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).
## What ships
### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
- BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.
### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.
### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.
### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.
### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.
### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.
### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.
## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
|
||
|
|
323e66433e |
lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt regression Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog [post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094) This unifies our linting tooling --------- Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
c721fa8502 |
Add editor mode for text field widgets (#20779)
## Summary Tested ↓ <img width="3456" height="1990" alt="CleanShot 2026-05-20 at 21 14 04@2x" src="https://github.com/user-attachments/assets/b4e0d3d3-715f-4ad7-bd03-e8e1922b3c6c" /> - Enable `FieldDisplayMode.EDITOR` for plain `TEXT` field widgets while keeping `FIELD` as the default display mode. - Add a plain multiline text editor renderer for `TEXT + EDITOR` field widgets with optimistic record-store/cache updates and debounced persistence. - Reuse the shared `TextArea` component through a transparent, uncapped variant so the editor has no input chrome and lets the widget grow. - Add unit coverage for text display-mode config and a Storybook scenario for a text field widget in editor mode. ## useEffect cleanup note `FieldWidgetTextEditor` flushes the debounced persist callback in a `useEffect` cleanup: ```ts useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]); ``` This follows the existing debounced autosave cleanup pattern already used in `WorkflowEditActionHttpRequest`. It ensures pending text changes are persisted when the widget unmounts, while `onBlur` still flushes immediately for normal editor exits. ## Validation - `npx nx test twenty-front --testPathPattern=page-layout` - `npx nx typecheck twenty-front` - `npx nx lint twenty-front` - Browser check on `http://apple.localhost:3001/object/company/20202020-a305-41e7-8c72-ba44072a4c58` for transparent textarea, no internal max-height/scroll, equal padding, and widget growth. Note: lint passes with two unrelated existing warnings in `NavigationDrawerItem.tsx` and `ConfigVariableEdit.tsx`. --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
a9ff1a9d3c |
Fix server variable not shown (#20799)
## Before <img width="1502" height="675" alt="image" src="https://github.com/user-attachments/assets/b64b24b4-11a5-4f6b-a3aa-c77108f22e9d" /> ## After <img width="1262" height="593" alt="image" src="https://github.com/user-attachments/assets/42d662b4-2ec6-4ad4-9d19-58f25f52475c" /> --------- Co-authored-by: ehconitin <nitinkoche03@gmail.com> |
||
|
|
3d49c17e34 |
[CONNECTED_ACCOUNT_BREAKING_CHANGE] Unify connected account permissions (#20732)
# Introduction This PR is a followup of https://github.com/twentyhq/twenty/pull/20673 It aims to unify the authentication/permissions layer with all the connectedAccount interactions across the application ## Deprecate - findAll - findById ## Email sync An user can only sync the message of his own connected account ## Workflow email - Related https://github.com/twentyhq/private-issues/issues/478 - Only reauthorize owned account |
||
|
|
57f13c9b92 |
[CONNECTED_ACCOUNT_BREAKING_CHANGE] Encrypt ConnectedAccount connectionParameters (#20673)
# Introduction Prevent any cross user `connectedAccount` `connectionParamaters` leak Also encrypt in db all `connectionParameters` password Never return any password through `DTO` anymore The settings now allow update mutation without providing the password in edition mode Verified all `connectionParameters.password` interaction ## Integration tests - Added more coverage for both failing and successful paths - Introduced a new env var that allow bypass the provider connection test ## Legacy connected Account decryption support Stop allowing non encrypted decryption on `accessToken` and `refreshToken`, only allow legacy decryption on refactored `connectionParameters` ## Upsert ownership Completely got rid of the connected workspace schema context which is legacy Also now a user can only upsert a connected account for him only.. ## New UI <img width="1770" height="1852" alt="image" src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b" /> If in edition the password is by default disabled It needs to be selected as being edited to be enabled ## Next - Refactor tool permissions flag not to include connected accounts - Remove the legacy connected standard object - Refactor and improve connected account resolver auth |
||
|
|
72c0c36db5 |
fix(twenty-front): prevent connected account row overflow on long status label (#20713)
Reproducible on German language Before <img width="638" height="262" alt="SCR-20260519-ofhi" src="https://github.com/user-attachments/assets/633ddd9a-203d-472a-bf29-379d6f088e80" /> After <img width="806" height="434" alt="SCR-20260519-oesa" src="https://github.com/user-attachments/assets/ca37c06b-e439-4156-8447-0e75a5f3fe9c" /> /closes #20594 |
||
|
|
db0547f503 |
[1/3] Rename permissionFlag to rolePermissionFlag + add permissionFlag catalog/backfill (#20481)
Split of #20377. ## Summary This PR separates available permission flags from per-role permission flag grants. Previously, `core.permissionFlag` stored the role assignment directly: `roleId + flag`. This PR renames that legacy grant table to `core.rolePermissionFlag`, then recreates `core.permissionFlag` as the catalog of available permission flags. ## What changed - Rename the existing `core.permissionFlag` grant table to `core.rolePermissionFlag`. - Add the new syncable `core.permissionFlag` catalog entity with key, label, description, icon, permission type, relevance flags, and custom/standard metadata. - Add stable `SystemPermissionFlag` universal identifiers for the built-in `PermissionFlagType` values. - Seed the standard permission flags for every workspace under the Twenty standard application. - Backfill existing role grants: - create missing catalog rows for existing grant keys, - add `rolePermissionFlag.permissionFlagId`, - migrate grants from the old string `flag` column to the new catalog FK, - replace the old `(flag, roleId)` uniqueness with `(permissionFlagId, roleId)`. - Rewire role permission flag caches, permission checks, role DTO mapping, and `upsertPermissionFlags` to resolve through the catalog. - Keep the existing public role permission API shape: product/app surfaces still talk about `permissionFlags` and return `{ id, roleId, flag }`. - Update metadata flat-entity machinery, migration builders, validators, action handlers, snapshots, generated schemas, docs, and app fixtures for the new `permissionFlag` / `rolePermissionFlag` split. ## Behavior after this PR - Existing permission flag grants keep working. - Existing GraphQL role permission flows keep the same public naming. - Standard permission flags are represented as catalog rows. - Permission checks now compare grants through catalog universal identifiers instead of the legacy `flag` column. - Workspace deletion cleanup now verifies both `permissionFlag` and `rolePermissionFlag`. ## What is not in this PR - Public GraphQL CRUD for custom permission flags. - App manifest support for declaring new custom permission flags. - Frontend UI for creating or assigning custom permission flags beyond the existing role permission flow. --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
c938fbf4d6 |
feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)
**Stacked on #20527** https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd ## Summary Surfaces the one-hop relation traversal added in #20527 through the existing **composite sub-field dropdown pattern**. Clicking a MANY_TO_ONE relation field in the "+ Filter" picker now opens the same second-level dropdown that composite fields (FULL_NAME, ADDRESS, CURRENCY, etc.) already use — populated with the target object's filterable fields. Picking one (e.g. `Company → Name`) builds a filter that serializes to the nested GraphQL filter the backend now accepts: `{ company: { name: { ilike: "%X%" } } }`. No new components. The whole feature reuses `AdvancedFilterSubFieldSelectMenu` + the existing `subFieldNameUsedInDropdownComponentState` + the existing `MenuItem hasSubMenu` indicator. Only the conditions that gate the sub-menu (and the sub-menu's content for relations) were broadened. ## What landed | File | Change | |---|---| | `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). | | `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu alongside composite clicks. | | `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu type is `'RELATION'`, render the target object's filterable fields via `useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite logic untouched. | | `objectFilterDropdownSubMenuFieldType` state | Widened to accept a `'RELATION'` sentinel. Role-permissions sub-field menu narrows it back out (it doesn't traverse relations). | | `useSelectFieldUsedInAdvancedFilterDropdown` | New optional `targetFieldMetadataItem` arg. When present, the stored RecordFilter's `type` is the target field's type so the operand picker and value input render the target's operands (`'TEXT'` operators when filtering `company.name`, etc.). | | `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter targets a `RELATION` field with a `subFieldName`, synthesize a field-metadata for the target, recurse to build the inner filter, then wrap it under the relation field's name → `{ relationName: { targetFieldName: { ...operator } } }`. | `RecordFilter.subFieldName` stays narrowly typed as `CompositeFieldSubFieldName` so the wide downstream consumers (`shouldShowFilterTextInput`, composite handlers in the serializer, etc.) don't change. The relation target field's name is stored through a narrowly-scoped cast at the dropdown's storage point — the serializer checks `filter.type === 'RELATION'` before interpreting it as a target field name, so the cast can't be mis-read by composite-only code paths. ## Test plan - [ ] Open a table view on People, click "+ Filter", click "Company" → sub-menu opens with Company's filterable fields - [ ] Pick "Name" → operand picker shows TEXT operators (Contains, Equals, …) - [ ] Type "Airbnb" → filter applies, table shows people whose company name contains "Airbnb" - [ ] Verify network tab: the GraphQL filter variable is `{ company: { name: { ilike: "%Airbnb%" } } }` - [ ] Same flow with a composite target field (e.g. `Company → annualRecurringRevenue → amountMicros`) — should work end-to-end (backend supports composite-within-relation; #20527 has an integration test covering this) - [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal sub-menu and filter correctly — no regression - [ ] Role-permissions field-select sub-field menu is unaffected (it bails out early on the RELATION sentinel) ## Out of scope - ONE_TO_MANY traversal (no backend support yet) - Aggregates (`people.count > 5`) - Persisting relation-traversal filters into a saved view (ViewFilter has no `relationPath` column yet; that's a separate slice) - REST API DSL changes - AI Tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
75b9b2fe5d |
feat(admin-panel): signing keys management tab with usage tracking (#20586)
## Summary - Adds a new admin-only **Security** tab to the Admin Panel (alongside General/Apps/AI/Config/Health) containing a **Signing Keys** section. The tab is intentionally introduced now so the upcoming **Encryption rotation** work can land as a sibling section. - Lists every JWT signing key with key id, `createdAt`, `revokedAt`, current/active/revoked status, and a **7-day verification count** read from Redis. A trailing row aggregates **legacy HS256** verifications so it is clear when the deprecated path is still in use. - Lets an admin **revoke** a public key. Revoking the current key drops `isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and clears the in-process cached current key; the existing lazy path in `JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current key on the next sign. ## Backend - `SigningKeyVerifyCounterService` — bucketed Redis counter under the existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL refreshed on every increment, batched read via `mget`. Failures are swallowed and logged at `warn` so a Redis hiccup cannot break auth. - `JwtWrapperService.verifyJwtToken` records verifies **after success** for both ES256 (`kid` as identifier) and HS256 (the literal `legacy` identifier). - `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`: list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent, validates the UUID, invalidates the public-key cache, and resets the cached current-key promise. - `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey` (mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they are admin-only, like the 35 existing admin-only methods on this resolver. `privateKey` is never returned over GraphQL. ## Frontend - New `SECURITY` tab id wired into `SettingsAdminContent` and `SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`). - `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly reuse existing admin-panel components: `Section`, `H2Title`, `Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`, `Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the queue retry/delete modals. Only one minimal styled helper for the monospaced UUID rendering. - `useRevokeSigningKey` uses `useApolloAdminClient`, refetches `GetSigningKeys`, shows success/error snackbars (same pattern as `useRetryJobs`/`useDeleteJobs`). <img width="1293" height="881" alt="image" src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c" /> |
||
|
|
e16977f97b |
[breaking: deploy server before front] feat(view-sort): pick sort sub-field inline on the chip (#20445)
## Summary Lets users choose which sub-field of a composite column to sort by — directly from the sort chip — by clicking the sub-field label and picking from a dropdown. Persists per view via a new nullable \`subFieldName\` column on \`ViewSort\`. Replaces #20438, which proposed a field-settings (admin) configuration for the same problem. The chip-level approach is more discoverable (the option lives where the user is looking) and per-view, so different views on the same object can sort by different sub-fields. ### What changes for users - **FullName columns**: previously sorted by \`firstName\` and \`lastName\` together as a stable dual-key sort. Now the user can pick which sub-field is primary (the other is the tie-breaker). Default remains \`firstName\` primary, \`lastName\` tie-breaker. - **Address columns**: previously not sortable at all (not in \`SORTABLE_FIELD_METADATA_TYPES\`). Now sortable, with a chip dropdown listing each enabled sub-field. Default is \`addressCity\` if enabled, else the first enabled sub-field. Disabling a sub-field at the field-metadata level (existing setting) removes it from the dropdown. - **Other composite types** (Currency, Phones, Emails, Links, Actor) and scalar fields keep their existing single-key sort behavior. ### UX ``` ┌─────────────────────────┐ ┌─────────────────────────┐ │ ↑ Name · Last name ✕ │ │ ↑ Address · City ✕ │ └────────┬────────────────┘ └────────┬────────────────┘ ▼ (click sub-field) ▼ ┌────────────┐ ┌────────────┐ │ First name │ │ Address 1 │ │ Last name ✓│ │ Address 2 │ └────────────┘ │ City ✓│ │ State │ │ Postcode │ │ Country │ └────────────┘ ``` The chip body still toggles direction on click — the \`Dropdown\`'s internal wrapper calls \`stopPropagation\` so the sub-field click doesn't bubble to the chip's onClick. ## What changed **Backend:** - \`ViewSortEntity\` — new nullable \`subFieldName: varchar\` column - \`ViewSortDTO\`, \`CreateViewSortInput\`, \`UpdateViewSortInputUpdates\` — new \`@Field(() => String, { nullable: true })\` - \`FLAT_VIEW_SORT_EDITABLE_PROPERTIES\` — \`'subFieldName'\` added so the property flows through the update merge path - \`ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME.viewSort\` — new \`subFieldName\` entry with \`toCompare: true\` so cache diffs notice it - \`fromCreateViewSortInputToFlatViewSortToCreate\` — threads \`subFieldName\` through - Instance command migration (\`add-sub-field-name-to-view-sort\`) — single \`ALTER TABLE core.viewSort ADD subFieldName varchar\` / \`DROP\` **Frontend:** - \`RecordSort\` and \`ViewSort\` types — \`subFieldName?: string | null\` - \`VIEW_SORT_FRAGMENT\` — adds \`subFieldName\` so the field round-trips - \`mapRecordSortToViewSort\` + \`areViewSortsEqual\` — carry the new field through, include it in the diff so the usual \`useSaveRecordSortsToViewSorts\` create/update flow fires when it changes - \`useSaveRecordSortsToViewSorts\` — passes \`subFieldName\` in both \`CreateViewSortInput\` and \`UpdateViewSortInputUpdates\` - \`getOrderByForFieldMetadataType(field, direction, subFieldName?)\` — new optional third arg. \`turnSortsIntoOrderBy\` threads \`sort.subFieldName\` into it. - \`Address\` added to \`SORTABLE_FIELD_METADATA_TYPES\` - New helpers: \`getEnabledAddressSubFields\` (filters by the field's \`subFields\` setting, falls back to the 6 default visible address sub-fields), \`getDefaultSortSubFieldForAddress\`, \`getDefaultSortSubFieldForFullName\` - New shared types/constants: \`AllowedFullNameSubField\`, \`ALLOWED_FULL_NAME_SUBFIELDS\`, \`DEFAULT_VISIBLE_ADDRESS_SUBFIELDS\` - \`SortOrFilterChip\` — new \`labelSubField?: ReactNode\` slot; renders as \` · {sub-field}\` with subdued weight after the main label - \`EditableSortChip\` — builds options from field metadata (\`ALLOWED_FULL_NAME_SUBFIELDS\` for FullName, \`getEnabledAddressSubFields\` for Address), uses i18n-wrapped labels, persists picks via \`upsertRecordSort\` ## Test plan - [x] \`npx nx typecheck\` passes for twenty-shared, twenty-front, twenty-server - [x] \`oxlint --type-aware\` on all 19 frontend + 9 server changed files: 0 errors - [x] \`prettier --check\`: clean - [x] 16 unit tests pass — \`getOrderByForFieldMetadataType\` covers the new \`subFieldName\` override branch for FULL_NAME and ADDRESS; \`getDefaultSortSubFieldForAddress\` covers the city/first-enabled fallback path; \`getDefaultSortSubFieldForFullName\` exercises its constant - [ ] Manual: sort a People view by Full Name → click the chip's sub-field label → switch between First name and Last name → reload page → choice is preserved - [ ] Manual: sort a Company view by Address → confirm dropdown lists only enabled sub-fields → disable Address \`addressCity\` in field settings → confirm dropdown options update and runtime falls back to the first enabled sub-field 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
fcd2d586ee |
chore(billing) - remove feature flag (#20531)
- remove feature flag - remove old enforce cap usage logic |
||
|
|
dea1f89904 |
Inject none secret env variables into front components (#20511)
## Summary - Inject non-secret application variables (`isSecret: false`) into front component `process.env` via the existing Web Worker `setWorkerEnv` mechanism - Filter secret variables server-side in the resolver so they never reach the browser - Set application variables before system variables (`TWENTY_API_URL`, `TWENTY_APP_ACCESS_TOKEN`) to prevent override - Wire up environment variable keys in the logic function code editor for TypeScript autocomplete ## Test plan - [x] Unit tests for `buildNonSecretEnvVar` (6 passing) - [x] Typecheck passes for `twenty-front` and `twenty-server` - [x] Install an app with both `isSecret: false` and `isSecret: true` variables, open a front component, verify only non-secret vars appear in `process.env` - [x] Open a logic function editor, verify autocomplete suggests declared variable keys |
||
|
|
bbc55193f5 |
Fix phone unique constraints (#20261)
## Summary Closes #20195 Fix phone field unique constraints so phone numbers are considered unique by both `primaryPhoneNumber` and `primaryPhoneCallingCode`. - Include `primaryPhoneCallingCode` in the shared phone composite unique constraint metadata - Align the frontend settings composite field config with the backend metadata - Return all included unique composite subfields when building create-many conflict fields - Match composite unique conflict fields as a group during create-many upserts ## Root Cause Phone composite metadata only marked `primaryPhoneNumber` as part of the unique constraint. That made different international phone numbers with the same national number conflict, for example `+1 123456789` and `+32 123456789`. ## Test Plan - `yarn workspace twenty-shared build` - `jest --runTestsByPath <index action handler and create-many utility specs>` - `prettier --check <touched files>` - `oxlint --type-aware <touched files>` - `nx run twenty-shared:typecheck` - `nx run twenty-server:typecheck` - `nx run twenty-front:typecheck` --------- Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: prastoin <paul@twenty.com> |
||
|
|
c4e897a7b5 |
Improve linear app (#20453)
- Add front component form to create linear issue <img width="1512" height="831" alt="image" src="https://github.com/user-attachments/assets/ffbb223f-30a8-4c64-ac6d-002c29b604c1" /> <img width="1512" height="829" alt="image" src="https://github.com/user-attachments/assets/a5ed2464-35a9-4a60-804c-5f15eb0043b4" /> - improve marketplace Linear app page <img width="1302" height="834" alt="image" src="https://github.com/user-attachments/assets/cdec7ec2-953d-4a49-a797-5369834b03c1" /> - update admin settings to display non secret values <img width="861" height="473" alt="image" src="https://github.com/user-attachments/assets/41dadf02-aa5d-4eb6-befe-0ad8ad4049b2" /> |
||
|
|
b03f044d0f |
feat(messaging): add workspace toggle to sync internal emails (#20457)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
e7032d0638 |
fix: prevent admin panel workspace upgrade error from overflowing the table (#20394)
## Summary In the admin panel workspace detail page, the **Upgrade Status > Last error** row was rendering the raw `errorMessage` string directly. Long messages (typically full stack traces) overflowed the table cell and overlapped neighbouring rows, breaking the layout. The `Last command` row in the same table already uses `OverflowingTextWithTooltip` (the helper used elsewhere in settings tables) to clamp long values to a single line and reveal the full text in a tooltip on hover. This PR applies the same treatment to the `Last error` row, with `isTooltipMultiline` so newlines in the stack trace are preserved when the tooltip opens. ## Test plan - [ ] Open Admin Panel > a workspace with a failed upgrade and verify the `Last error` row stays on a single line with an ellipsis - [ ] Hover the row and verify the full multi-line error is shown in the tooltip - [ ] Verify other rows (Last command, Last updated, etc.) and the workspace info section are unaffected |
||
|
|
34b927ff23 |
feat(public-domain): bind public domains to apps + reorganize settings (#20360)
## Summary - **Public domains can now be bound to a specific app.** When a request hits an app-bound public domain, route resolution restricts logic-function matching to that app's HTTP-routed functions only — isolating each app's routes to its own domain instead of letting routes from other apps in the workspace match nondeterministically. - **Settings sidebar reorganized.** Removed the standalone Domains page. Workspace Domain → General. Approved Domains + Invitations → Members "Access" tab. Emailing Domains + Public Domains → Apps "Developer" tab. Roles → Members "Roles" tab. ## Why The use case: someone building a partner portal app or a lead-collection app declares private objects (leads, partners…) plus a few public HTTP routes. Each app needs its own domain (`partners.acme.com`, `leads.acme.com`) without those domains exposing every other app's routes in the same workspace. Today's PublicDomainEntity is workspace-scoped only, so all HTTP-routed logic functions in a workspace compete for any public domain — first match wins nondeterministically. ## Backend - Added nullable `applicationId` FK to `PublicDomainEntity` (cascade-deleted with the app); indexed for the route-trigger lookup. - New fast instance command `2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain` adds the column, index, and FK constraint. - `createPublicDomain(domain, applicationId)` accepts an optional app binding; new `updatePublicDomain(domain, applicationId)` mutation rebinds/unbinds an existing domain. Both validate the application belongs to the workspace. - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain(origin)` returns both the workspace and the matched public domain in one query — replacing the old back-to-back lookups in the route-trigger hot path. `getWorkspaceByOriginOrDefaultWorkspace` is preserved as a thin wrapper. - `RouteTriggerService` filters `logicFunction` by `applicationId` when the matched public domain is app-scoped; falls back to workspace-wide when unbound. - Three sequential validation queries in `createPublicDomain` now run in parallel via `Promise.all`. ## Frontend | Old location | New location | |---|---| | Settings sidebar → Domains (standalone page) | Removed | | Domains page → Workspace Domain | General page | | Domains page → Approved Domains | Members → Access tab | | Domains page → Emailing Domains | Apps → Developer tab | | Domains page → Public Domains | Apps → Developer tab | | Settings sidebar → Roles (standalone) | Members → Roles tab | | `pages/settings/roles/` | `pages/settings/members/roles/` | - The Public Domain detail page has an Application picker that uses `Select`'s native `emptyOption` + `null` value pattern (matches `SettingsDataModelObjectIdentifiersForm`). - Members page tabs use the existing `TabListFromUrlOptionalEffect` mechanism (rendered automatically by `TabList`) for hash-based tab activation. - `/settings/members/roles` redirects to `/settings/members#roles` so role sub-pages' `navigate(SettingsPath.Roles)` lands on the Members page with the Roles tab pre-selected. - All affected breadcrumbs updated to nest under their new parents. - `SettingsPath.Roles` and friends now nest under `members/`; `Subdomain` and `CustomDomain` under `general/`; `PublicDomain` and `EmailingDomain` under `applications/`. ## Test plan - [x] `nx typecheck twenty-front` passes - [x] `nx typecheck twenty-server` passes - [x] `oxlint --type-aware` clean on all touched files - [x] `prettier --check` clean on all touched files - [x] Migration applied locally; `publicDomain.applicationId` (uuid, nullable) confirmed in DB - [x] GraphQL schema exposes `PublicDomain.applicationId`, `createPublicDomain.applicationId`, `updatePublicDomain` mutation - [x] **End-to-end route resolution scenarios verified locally:** - Domain bound to App A, function in App A → route matches ✅ - Domain bound to App B, function in App A → route does NOT match (HTTP 404 `TRIGGER_NOT_FOUND`) ✅ - Domain unbound (`applicationId = NULL`) → route matches workspace-wide ✅ - Unknown path on bound domain → returns 404 cleanly ✅ - [x] UI sanity (browser-tested at `apple.localhost:3001`): - General page shows Workspace Domain card - Members page shows Team / Access / Roles tabs - Access tab combines Invite by link + by email + Approved Domains - Roles tab embeds the role list - `/settings/members/roles` direct URL → redirects + Roles tab pre-selected - Apps Developer tab shows Emailing Domains + Public Domains sections - Public Domain detail page has Application picker dropdown listing workspace apps - Sidebar nav: "Domains" and "Roles" no longer present (now folded into General/Members) ## Notes for reviewers - Creating a public domain via the UI still requires Cloudflare credentials in the dev `.env` (`CLOUDFLARE_API_KEY`, `CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID`, `PUBLIC_DOMAIN_URL`). The DNS step is unchanged from main. - The `applicationId` column is nullable, so existing public-domain rows continue to work workspace-wide — no data backfill required. - `SettingsRolesContainer` was deleted (no longer referenced after `SettingsRoles` index page was removed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4da8878697 |
feat: add email forwarding message channel (#19535)
## Summary - Add email forwarding as a new message channel type, allowing users to forward emails from addresses like `support@mycompany.com` into Twenty - Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron job, parsed, routed to the correct workspace/channel, and persisted as messages - Dedicated settings page at `/settings/accounts/new-email-forwarding` where users provide their source email handle and receive a unique forwarding address - Forwarding channels bypass the IMAP/mailbox sync state machine — they skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages - Forwarding address section shown at the top of the Emails settings page so users can find/copy their addresses after initial setup - Tab names for forwarding channels display the user-provided handle (e.g. `support@mycompany.com`) instead of the internal routing address - Shared utilities extracted from IMAP driver: `extractThreadId`, `extractParticipants`, `extractAddresses` to avoid code duplication - Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/` prefix — no separate bucket needed - Feature gated behind `isEmailForwardingEnabled` client config (requires `INBOUND_EMAIL_DOMAIN` + S3 storage) ## New backend modules - `InboundEmailS3ClientProvider` — lazy-initialized S3 client using existing storage config - `InboundEmailStorageService` — S3 operations (get, move to processed/unmatched/failed) - `InboundEmailParserService` — RFC 822 parsing via `postal-mime`, builds `MessageWithParticipants` - `InboundEmailImportService` — orchestrates download → parse → route → persist → archive - `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix, enqueues import jobs - `CreateEmailForwardingChannelInput` DTO — accepts user-provided `handle` ## New frontend components - `SettingsAccountsNewEmailForwardingChannel` — dedicated page with handle input form + forwarding address result - `SettingsAccountsEmailForwardingSection` — forwarding address list on the Emails settings page - `useConnectedAccountHandleMap` — shared hook for account ID → handle lookup - `useCreateEmailForwardingChannel` — mutation hook accepting handle parameter ## Test plan - [x] 17 unit tests for inbound email import service (all outcomes: imported, unmatched, loop_dropped, unconfigured, parse_failed, persist_failed) - [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases - [x] 11 tests for `extractEnvelopeRecipient` utility - [x] TypeScript typechecks pass for both twenty-server and twenty-front - [x] Lint passes for both packages - [ ] Manual: create forwarding channel, verify forwarding address generated - [ ] Manual: send email to forwarding address, verify it appears in Twenty https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 <neo773@protonmail.com> |
||
|
|
9fc5be1c4c |
Billing - Migrate from Stripe metering (#20298)
**Overall strategy** **1. Introduce “Billing V2” behind a workspace flag** Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing workspaces stay on the old behavior until they’re migrated or explicitly on V2. **2. Replace workflow metered SKUs with a resource-credit product** Conceptually, billable “workflow execution” usage is not the primary subscription line item anymore. Add a RESOURCE_CREDIT product (and keep WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and limits are expressed through credit buckets (e.g. price metadata like credit_amount), so one product can represent pooled credits instead of a narrow workflow-only meter. **3. Migrate subscriptions in two layers** Schema/catalog: persist extra price metadata (instance upgrade) so the server knows credit amounts and can match Stripe prices to the new model. Per workspace: the registered workspace command upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT prices (using existing Stripe schedule + BillingSubscriptionUpdateService stack), then treats the workspace as V2 (flag). Workspaces without that legacy item or without a subscription are skipped. **4. Unify subscription lifecycle + usage on the server** **5. Refresh the product surface in Settings** Test : - [x] Subscribe v1 + Update subscribe + Migrate - [x] Subscribe v2 + Update subscribe |
||
|
|
948ed964bb |
Add isConfigured to application registration in App admin panel (#20326)
## After Added "Configured" column in admin panel apps tab <img width="1171" height="611" alt="image" src="https://github.com/user-attachments/assets/e6850b39-5789-4ad2-b3df-192a28cb03e2" /> Add a banner to ask to configure the application <img width="1218" height="483" alt="image" src="https://github.com/user-attachments/assets/1491363c-3479-41ca-97b7-c7dc9e07ff16" /> |
||
|
|
cbd2a017da |
Improve app gallery image sizing (no cropping) (#20287)
<img width="908" height="808" alt="Screenshot 2026-05-05 at 7 38 46 PM" src="https://github.com/user-attachments/assets/a6f0a9b7-f676-46a3-8642-48c4bd06c7f4" /> |
||
|
|
633553f729 |
feat(sdk): add defineCommandMenuItem (#20256)
## Summary - Add `defineCommandMenuItem` and `definePageLayoutWidget` as standalone SDK defines, mirroring the existing `definePageLayoutTab` pattern. Both entities can still be declared nested inside their parent (`defineFrontComponent.command` / `definePageLayout.tabs[].widgets[]`). - Add `CommandMenuItem` and `PageLayoutWidget` to the `SyncableEntity` enum and the dev-mode UI labels. - Wire the SDK manifest-build to extract the two new defines into top-level `commandMenuItems` / `pageLayoutWidgets` arrays on the manifest, and the server aggregator to consume them through the existing flat-entity converters. - On the server, expose `Application.commandMenuItems` (relation + DTO + service hydration in `findOneApplication`). - On the front, list command menu items in the application content tab and add a dedicated detail page with a settings tab, mirroring how `frontComponents` are surfaced. - Add `twenty add` templates and Vitest unit tests for both new defines. - Document the standalone-vs-nested pattern in `packages/twenty-sdk/README.md`. ### Why Until now, command menu items could only be declared as the nested `command:` field on `defineFrontComponent` — there was no way to register a command menu item from a separate file or from another package. The `SyncableEntity` enum had 12 values, while the server already synced 18 (including `commandMenuItem` and `pageLayoutWidget`). The same gap existed for `pageLayoutWidget`, which had no top-level define despite being synced server-side. This PR closes both gaps and aligns the SDK surface with what the server actually accepts. The standalone defines coexist with the nested form — pick one per entity, never both with the same `universalIdentifier` (the manifest aggregator will throw on duplicates). The README now documents this. ## Test plan - [x] `npx nx typecheck twenty-sdk` / `twenty-server` / `twenty-front` - [x] `npx nx lint:diff-with-main twenty-front` / `twenty-server` - [x] `npx nx lint twenty-sdk` / `twenty-shared` - [x] New unit tests: `define-command-menu-item.spec.ts`, `define-page-layout-widget.spec.ts` - [x] Existing manifest extract config tests still pass - [ ] Codegen `npx nx run twenty-front:graphql:generate --configuration=metadata` should be re-run after merge — the generated `graphql.ts` was patched manually to include `commandMenuItems` on `Application` and the `FindOneApplication` document. - [ ] Smoke test: scaffold an app with `twenty add` for both new entity types, run `twenty dev`, confirm the dev UI shows them in the sync list and the settings page surfaces command menu items in the content tab. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
53fdac1417 |
feat(apps): split AI tool and workflow action triggers in LogicFunction manifest (#20208)
## Summary Replaces the bolted-on `isTool` + `toolInputSchema` fields on `LogicFunctionManifest` with two distinct, opt-in triggers that align with the existing `cron` / `databaseEvent` / `httpRoute` trigger pattern: - **`toolTriggerSettings`** — exposes the function as an AI tool (chat / MCP / function calling). Uses standard JSON Schema (the format LLMs natively understand). - **`workflowActionTriggerSettings`** — exposes the function as a step in the visual workflow builder. Uses Twenty's rich `InputSchema` so the builder can render proper `FieldMetadataType`-aware editors, variable pickers, labels, and an optional `outputSchema`. A function can opt into none, one, or both. Each surface gets the schema format appropriate for it. ### Why `isTool: true` previously exposed the function as both an AI tool AND a workflow node, with the same JSON Schema feeding both — but the workflow builder really wants Twenty's `InputSchema` (with `CURRENCY`, `RELATION`, `EMAILS`, etc.) and the AI surface really wants standard JSON Schema. Today the workflow builder hacks around this by treating JSON Schema as `InputSchema`, which silently breaks for any non-primitive field type. Splitting the triggers fixes that and lets each surface evolve independently. ### Migration - **Fast** instance command adds the two new nullable columns. - **Slow** instance command backfills `toolTriggerSettings` + `workflowActionTriggerSettings` from `isTool=true` rows (preserving today's both-surfaces behaviour) then drops the legacy columns. ### Stacked Stacked on top of #20181. Merge that first, then this. ## Test plan - [ ] CI green (oxlint, typecheck, jest, vitest) - [ ] Run `--include-slow` upgrade against a workspace with existing `isTool=true` logic functions; verify both new columns populated and old columns dropped - [ ] Verify AI chat sees migrated tool functions (Linear create-issue, Exa search) and can call them with the JSON Schema - [ ] Add an AI-tool function from the Settings UI (toggles `toolTriggerSettings`) and verify it shows up in chat - [ ] Add a workflow-action function from the Settings UI (toggles `workflowActionTriggerSettings`) and verify it appears in the workflow node picker - [ ] In the workflow builder, edit a `LOGIC_FUNCTION` step and verify input fields render (no more JSON-Schema-as-InputSchema hack) - [ ] Try defining a function with no triggers in the SDK and verify `defineLogicFunction` rejects it 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: martmull <martmull@hotmail.fr> |
||
|
|
e3be1f4971 |
Make ConnectionProvider a true SyncableEntity (#20232)
## Summary PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but bypassing the standard sync pipeline — manifest sync called the bespoke `ApplicationOAuthProviderService.upsertManyFromManifest()` instead of going through the workspace-migration orchestrator like every other SyncableEntity. Anything that assumed *"all SyncableEntity values flow through the same pipeline"* (dev UI sync tracking, verification tooling) was wrong about ConnectionProvider — that's the inconsistency this PR closes. This PR follows the `.cursor/skills/syncable-entity-*` guides religiously, all six steps. ## What changes **Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`) - Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared) - Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops the ad-hoc columns since the base class provides them, adds `deletedAt`, drops the old `(applicationId, universalIdentifier)` unique in favour of SyncableEntity's `(workspaceId, universalIdentifier)`) - `FlatConnectionProvider`, `FlatConnectionProviderMaps`, `FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`, `UniversalFlatConnectionProvider`, six action types - Register in **all** the central registries: `AllFlatEntityTypesByMetadataName`, `ALL_METADATA_ENTITY_BY_METADATA_NAME`, `ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`, `ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`, `ALL_METADATA_SERIALIZED_RELATION`, `ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`, `WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`), `METADATA_EVENTS_TO_EMIT` - `case 'connectionProvider':` in seven discriminated-union switches (`derive-metadata-events-*`, `optimistically-apply-*`, `enrich-create-*`) **Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`) - `WorkspaceFlatConnectionProviderMapCacheService` (extends `WorkspaceCacheProvider`, decorated with `@WorkspaceCache`, soft-delete-aware) - `fromConnectionProviderEntityToFlatConnectionProvider` util - `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util - `FlatConnectionProviderModule` wires the cache service - Wired the manifest converter into `compute-application-manifest-all-universal-flat-entity-maps` **Step 3 — Builder & Validation** (`@syncable-entity-builder-and-validation`) - `FlatConnectionProviderValidatorService` — never throws, returns error arrays; uses indexed `byUniversalIdentifier` for the (name, applicationUniversalIdentifier) uniqueness check (no `Object.values().find()` on the hot path) - `WorkspaceMigrationConnectionProviderActionsBuilderService` - Registered in both validators-module + builder-module - **Wired into the orchestrator** (the most-commonly-forgotten step per the rule) — constructor inject, destructure `flatConnectionProviderMaps`, `validateAndBuild`, append actions to the final migration **Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`) - Three handlers (create / update / delete) using the canonical `WorkspaceMigrationRunnerActionHandler` mixin - Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule` **Step 5 — Integration** (`@syncable-entity-integration`) - Delete the `upsertManyFromManifest` bypass on `ApplicationOAuthProviderService` - Remove the bypass call from `ApplicationSyncService` — manifest sync now flows through the standard pipeline - Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule` (no longer needed) - Import `FlatConnectionProviderModule` from `ApplicationOAuthProviderModule` to keep the cache discoverable - 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`, `CONNECTION_PROVIDER_NOT_FOUND`, `CONNECTION_PROVIDER_NAME_ALREADY_EXISTS` **Migration** - Generated via `database:migrate:generate` (instance command `1777896012579`): drops the old `(applicationId, universalIdentifier)` unique constraint, adds `deletedAt` column, adds the `(workspaceId, universalIdentifier)` unique index that `SyncableEntity` requires. - Verified clean — a second `migrate:generate` pass produces zero drift. **Step 6 — Tests** (`@syncable-entity-testing`) - 3 new specs for the manifest converter (defaults, optional fields, all-fields) - All 32 existing OAuth-provider tests still pass - ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven only), so the GraphQL integration suite that other SyncableEntities ship doesn't apply here **Codegen** - Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk) against the live schema ## Why this matters Before: - `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum) - But the entity didn't extend `SyncableEntity` - And the manifest sync bypassed the standard pipeline - → Verification tooling, dev UI sync tracking, anything iterating over `ALL_METADATA_NAME` got inconsistent behaviour After: - `ConnectionProvider` is a `SyncableEntity` end-to-end - Single sync path through the workspace-migration orchestrator (same as `agent`, `skill`, `frontComponent`, `webhook`, …) - One mental model ## Out of scope (deliberate) - **Renaming the table** from `applicationOAuthProvider` to `connectionProvider` — the `metadataName` is `connectionProvider` (what consumers see in code); the table name is internal. A rename would balloon this PR with mechanical churn unrelated to the sync-pipeline wiring. Worth doing as a follow-up. - **`applicationVariable` SyncableEntity conversion** — the other manifest-sync holdout. Tracked in #20215. ## Test plan - [ ] Migration up/down clean against fresh DB - [ ] Install an app whose manifest declares connection providers — providers appear in the workspace - [ ] Re-deploy the app with one provider added, one removed, one renamed → all reconciled correctly via the sync pipeline - [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider entries the same way it shows agents/skills/etc - [ ] OAuth flow still works (existing connections, new connections, reconnect, list/get from SDK) — should be unchanged since the runtime code path didn't move 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e6399b180e |
Fix/workspace member avatars 20193 (#20200)
Fixes #20193 **Bug Description:** Previously, workspace member avatars failed to render correctly in table views and relation chips (such as the Account Owner field). While the avatar picker dropdown correctly fetched fresh GraphQL data, table views and chips relied on the cached defaultAvatarUrl or avatarUrl fields, which were frequently resolving to empty strings or failing to parse external OAuth URLs correctly. **Root Cause:** - Empty String Defaults: Deleting an avatar or failing to retrieve one defaulted the database state to an empty string ("") instead of null, which caused frontend image components to break rather than render their fallback states. - Missing Permanent URLs: The WorkspaceMemberTranspiler was strictly expecting internal signed URLs. If an avatar was an external OAuth URL, it incorrectly returned an empty string, breaking SSO profile pictures. - Missing Fallbacks: New users lacked a proper Gravatar fallback assignment upon workspace creation. **Changes Made:** - user-workspace.service.ts: Updated the avatar computation logic during user creation to implement a reliable Gravatar fallback and correctly set missing avatars to null instead of empty strings. Updated the storage to use permanent file URLs. - file-url.service.ts: Implemented a getRawFileUrl method to support rendering permanent, non-expiring file URLs for avatars. - workspace-member-transpiler.service.ts: Refactored the URL transpilation logic to gracefully pass through external OAuth URLs (e.g., Google/Microsoft profile pictures) instead of stripping them. - WorkspaceMemberPictureUploader.tsx: Fixed the frontend removal logic so that deleting a profile picture sets the avatarUrl to null (consistent with the backend) rather than an empty string. **Testing:** - Verified that avatars correctly display in relation chips and table views. - Verified that external OAuth avatars load properly. - Verified that deleting an avatar correctly resets the UI to the fallback initials component. Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Cursor <cursoragent@cursor.com> |