fix(front): show readable targets in morph relation picker (#21513)

## 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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21513?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-12 23:28:50 +02:00
committed by GitHub
parent 4614fe963c
commit ee6fcdbec2

View File

@@ -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,
]);