From ee6fcdbec22fdd2ee0ec880816d7baf97bd9703a Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 12 Jun 2026 23:28:50 +0200 Subject: [PATCH] fix(front): show readable targets in morph relation picker (#21513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The **morph relation picker is broken** when the user does not have read permission on *every* object a morph relation can point to. A morph (polymorphic) relation can target several objects. The single-record picker passed **all** of those target objects to the `search` query via `includedObjectNameSingulars`. The backend runs the per-object searches inside a single `Promise.all`, so if **one** target object is forbidden, the whole search rejects and the picker shows **"No records found"** — even for the target objects the user *can* read. This makes the morph relation picker unusable in any workspace where a role restricts read access to one of the morph targets (e.g. the demo workspace's `Object-restricted` role, which denies reading `Rocket` — the picker for a Pet's polymorphic owner then shows nothing, hiding the readable `Survey result` records too). ## Fix Filter the searched target objects down to the ones the current user is allowed to read before querying, in `useSingleRecordPickerPerformSearch`. This mirrors what the multiple-record picker (`useMultipleRecordPickerPerformSearch`) already does via `filteredSearchableObjectMetadataItems`. For a normal (single-target) relation this is a no-op; for a morph relation the picker now lists records from every target the user can read and silently skips the forbidden ones. ## Test Added `useSingleRecordPickerPerformSearch.test.tsx`: - excludes morph target objects the user cannot read from the search - keeps all targets when the user can read all of them ## Verification Reproduced locally on a Pet's "Polymorphic Owner" morph relation (targets `Rocket` + `Survey result`) with `Rocket` read denied: - **Before:** picker shows "No records found". - **After:** picker lists the readable `Survey result` records and omits the `Rocket` ones. Review in cubic --- .../useSingleRecordPickerPerformSearch.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerPerformSearch.ts b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerPerformSearch.ts index c4a00897cdd..acc51f501e5 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerPerformSearch.ts +++ b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerPerformSearch.ts @@ -3,11 +3,13 @@ import { useStore } from 'jotai'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords'; import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState'; import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext'; import { singleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchableObjectMetadataItemsComponentState'; import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { CustomError, isDefined } from 'twenty-shared/utils'; @@ -33,6 +35,26 @@ export const useSingleRecordPickerPerformSearch = ({ ); const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const readableObjectNameSingulars = objectNameSingulars.filter( + (objectNameSingular) => { + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === objectNameSingular, + ); + + if (!isDefined(objectMetadataItem)) { + return false; + } + + return ( + getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: objectMetadataItem.id, + }).canReadObjectRecords === true + ); + }, + ); const hasSelectedIds = selectedIds.length > 0; const selectedIdsFilter = hasSelectedIds @@ -41,7 +63,7 @@ export const useSingleRecordPickerPerformSearch = ({ const { loading: selectedRecordsLoading, searchRecords: selectedRecords } = useObjectRecordSearchRecords({ - objectNameSingulars, + objectNameSingulars: readableObjectNameSingulars, filter: selectedIdsFilter, skip: !hasSelectedIds, searchInput: '', @@ -51,7 +73,7 @@ export const useSingleRecordPickerPerformSearch = ({ loading: filteredSelectedRecordsLoading, searchRecords: filteredSelectedRecords, } = useObjectRecordSearchRecords({ - objectNameSingulars, + objectNameSingulars: readableObjectNameSingulars, filter: selectedIdsFilter, skip: !hasSelectedIds, searchInput: searchFilter, @@ -63,7 +85,7 @@ export const useSingleRecordPickerPerformSearch = ({ : undefined; const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } = useObjectRecordSearchRecords({ - objectNameSingulars, + objectNameSingulars: readableObjectNameSingulars, filter: notFilter, limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT, searchInput: searchFilter, @@ -92,14 +114,14 @@ export const useSingleRecordPickerPerformSearch = ({ instanceId: singleRecordPickerInstanceId, }), objectMetadataItems.filter((objectMetadataItem) => - objectNameSingulars.includes(objectMetadataItem.nameSingular), + readableObjectNameSingulars.includes(objectMetadataItem.nameSingular), ), ); }, [ allSearchRecords, store, objectMetadataItems, - objectNameSingulars, + readableObjectNameSingulars, singleRecordPickerInstanceId, ]);